From 9e368cbf37f39732e558cb7d617ef005ff394a01 Mon Sep 17 00:00:00 2001 From: hailin Date: Sat, 7 Mar 2026 11:15:03 -0800 Subject: [PATCH] fix(mobile+backend): fix notification DNS, message page API bugs, add read-all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android DNS fix (native_dio_adapter): - Dart's getaddrinfo() fails on some Android 12+ devices when browser works fine — root cause is Dart native socket using different DNS path than Android Java stack; fix by using NativeAdapter (OkHttp) on Android - Add native_dio_adapter ^1.5.1 to pubspec; enable in ApiClient for Android Message page bugs (5 fixes): - Fix offset->page: Flutter sent ?offset=0 but backend NotificationQueryDto uses page/limit (1-based); now sends ?page=1&limit=50 - Fix announcement tab API: Tab 2 now calls /api/v1/announcements (separate resource) instead of /api/v1/notifications?type=ANNOUNCEMENT - Fix markAllAsRead routing: calls correct API based on active tab - Fix markAsRead routing: announcement items use announcements API - Fix detail navigation: passes NotificationItem as route argument Backend notification-service: - Add PUT /api/v1/notifications/read-all endpoint - Add markAllReadByUserId() to repository interface + TypeORM implementation - Add markAllRead() to NotificationService i18n (4 languages): add time.justNow/minutesAgo/hoursAgo/daysAgo keys Co-Authored-By: Claude Sonnet 4.6 --- .../services/notification.service.ts | 4 + .../notification.repository.interface.ts | 1 + .../persistence/notification.repository.ts | 7 + .../controllers/notification.controller.ts | 7 + .../genex-mobile/lib/app/i18n/strings/en.dart | 4 + .../genex-mobile/lib/app/i18n/strings/ja.dart | 4 + .../lib/app/i18n/strings/zh_cn.dart | 6 + .../lib/app/i18n/strings/zh_tw.dart | 4 + .../lib/core/network/api_client.dart | 10 + .../core/services/notification_service.dart | 17 +- .../presentation/pages/message_page.dart | 190 +++++++++--------- frontend/genex-mobile/pubspec.yaml | 1 + 12 files changed, 163 insertions(+), 92 deletions(-) diff --git a/backend/services/notification-service/src/application/services/notification.service.ts b/backend/services/notification-service/src/application/services/notification.service.ts index 6cf0945..2328238 100644 --- a/backend/services/notification-service/src/application/services/notification.service.ts +++ b/backend/services/notification-service/src/application/services/notification.service.ts @@ -104,4 +104,8 @@ export class NotificationService { async countUnread(userId: string): Promise { return this.notificationRepo.countByUserIdAndStatus(userId, NotificationStatus.SENT); } + + async markAllRead(userId: string): Promise { + await this.notificationRepo.markAllReadByUserId(userId); + } } diff --git a/backend/services/notification-service/src/domain/repositories/notification.repository.interface.ts b/backend/services/notification-service/src/domain/repositories/notification.repository.interface.ts index 7a75814..c4c34c7 100644 --- a/backend/services/notification-service/src/domain/repositories/notification.repository.interface.ts +++ b/backend/services/notification-service/src/domain/repositories/notification.repository.interface.ts @@ -8,6 +8,7 @@ export interface INotificationRepository { create(data: Partial): Promise; save(notification: Notification): Promise; updateStatus(id: string, status: NotificationStatus, extra?: Partial): Promise; + markAllReadByUserId(userId: string): Promise; getStatusCounts(): Promise>; getChannelStatusCounts(): Promise>; countTodaySent(): Promise; diff --git a/backend/services/notification-service/src/infrastructure/persistence/notification.repository.ts b/backend/services/notification-service/src/infrastructure/persistence/notification.repository.ts index fbfdb70..1924bdd 100644 --- a/backend/services/notification-service/src/infrastructure/persistence/notification.repository.ts +++ b/backend/services/notification-service/src/infrastructure/persistence/notification.repository.ts @@ -45,6 +45,13 @@ export class NotificationRepository implements INotificationRepository { await this.repo.update(id, { status, ...extra }); } + async markAllReadByUserId(userId: string): Promise { + await this.repo.update( + { userId, status: NotificationStatus.SENT }, + { status: NotificationStatus.READ, readAt: new Date() }, + ); + } + async getStatusCounts(): Promise> { return this.repo .createQueryBuilder('n') diff --git a/backend/services/notification-service/src/interface/http/controllers/notification.controller.ts b/backend/services/notification-service/src/interface/http/controllers/notification.controller.ts index 8fac5ff..a5c3e80 100644 --- a/backend/services/notification-service/src/interface/http/controllers/notification.controller.ts +++ b/backend/services/notification-service/src/interface/http/controllers/notification.controller.ts @@ -24,6 +24,13 @@ export class NotificationController { return { code: 0, data: { count: await this.notificationService.countUnread(req.user.id) } }; } + @Put('read-all') + @ApiOperation({ summary: 'Mark all notifications as read' }) + async markAllAsRead(@Req() req: any) { + await this.notificationService.markAllRead(req.user.id); + return { code: 0, data: null }; + } + @Put(':id/read') @ApiOperation({ summary: 'Mark notification as read' }) async markAsRead(@Param() params: MarkReadParamDto, @Req() req: any) { diff --git a/frontend/genex-mobile/lib/app/i18n/strings/en.dart b/frontend/genex-mobile/lib/app/i18n/strings/en.dart index 39fdd21..c95347c 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/en.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/en.dart @@ -835,4 +835,8 @@ const Map en = { 'notification.empty': 'No notifications', 'notification.loadFailed': 'Load failed', 'notification.retry': 'Retry', + 'time.justNow': 'Just now', + 'time.minutesAgo': '{n}m ago', + 'time.hoursAgo': '{n}h ago', + 'time.daysAgo': '{n}d ago', }; diff --git a/frontend/genex-mobile/lib/app/i18n/strings/ja.dart b/frontend/genex-mobile/lib/app/i18n/strings/ja.dart index cf3ed69..b3b0ec8 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/ja.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/ja.dart @@ -836,4 +836,8 @@ const Map ja = { 'notification.empty': '通知なし', 'notification.loadFailed': '読み込み失敗', 'notification.retry': 'リトライ', + 'time.justNow': 'たった今', + 'time.minutesAgo': '{n}分前', + 'time.hoursAgo': '{n}時間前', + 'time.daysAgo': '{n}日前', }; diff --git a/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart b/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart index ca0db03..71ac600 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/zh_cn.dart @@ -836,4 +836,10 @@ const Map zhCN = { 'notification.empty': '暂无通知', 'notification.loadFailed': '加载失败', 'notification.retry': '重试', + + // ============ Time formatting ============ + 'time.justNow': '刚刚', + 'time.minutesAgo': '{n}分钟前', + 'time.hoursAgo': '{n}小时前', + 'time.daysAgo': '{n}天前', }; diff --git a/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart b/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart index d5ec0b4..2c48d4b 100644 --- a/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart +++ b/frontend/genex-mobile/lib/app/i18n/strings/zh_tw.dart @@ -836,4 +836,8 @@ const Map zhTW = { 'notification.empty': '暫無通知', 'notification.loadFailed': '載入失敗', 'notification.retry': '重試', + 'time.justNow': '剛剛', + 'time.minutesAgo': '{n}分鐘前', + 'time.hoursAgo': '{n}小時前', + 'time.daysAgo': '{n}天前', }; diff --git a/frontend/genex-mobile/lib/core/network/api_client.dart b/frontend/genex-mobile/lib/core/network/api_client.dart index 8690749..0d735e8 100644 --- a/frontend/genex-mobile/lib/core/network/api_client.dart +++ b/frontend/genex-mobile/lib/core/network/api_client.dart @@ -21,8 +21,10 @@ // ============================================================ import 'dart:async'; +import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; +import 'package:native_dio_adapter/native_dio_adapter.dart'; import '../storage/session_storage.dart'; /// Genex API 客户端 — 单例 @@ -56,6 +58,14 @@ class ApiClient { 'Accept': 'application/json', }, )); + + // Android 上使用 OkHttp(Java 网络栈)替代 Dart 原生 socket + // 解决 Dart 的 getaddrinfo() 在部分 Android 12+ 设备上 DNS 解析失败的问题 + // (浏览器走 Android Java 网络栈能正常解析,而 Dart native socket 不能) + if (Platform.isAndroid) { + _dio.httpClientAdapter = NativeAdapter(); + } + _dio.interceptors.add(_buildAuthInterceptor()); } diff --git a/frontend/genex-mobile/lib/core/services/notification_service.dart b/frontend/genex-mobile/lib/core/services/notification_service.dart index 8379277..022381c 100644 --- a/frontend/genex-mobile/lib/core/services/notification_service.dart +++ b/frontend/genex-mobile/lib/core/services/notification_service.dart @@ -129,15 +129,17 @@ class NotificationService { : _apiClient = apiClient ?? ApiClient.instance; /// 获取通知列表 + /// + /// [page] 从 1 开始(后端 NotificationQueryDto 使用 page/limit,不是 offset) Future getNotifications({ NotificationType? type, + int page = 1, int limit = 50, - int offset = 0, }) async { try { final queryParams = { + 'page': page, 'limit': limit, - 'offset': offset, }; if (type != null) { queryParams['type'] = type.name.toUpperCase(); @@ -169,6 +171,17 @@ class NotificationService { } } + /// 全部通知标记已读 + Future markAllNotificationsAsRead() async { + try { + await _apiClient.put('/api/v1/notifications/read-all'); + return true; + } catch (e) { + debugPrint('[NotificationService] 全部通知标记已读失败: $e'); + return false; + } + } + /// 标记通知为已读 Future markAsRead(String notificationId) async { try { diff --git a/frontend/genex-mobile/lib/features/message/presentation/pages/message_page.dart b/frontend/genex-mobile/lib/features/message/presentation/pages/message_page.dart index 3a804a8..d0e10ca 100644 --- a/frontend/genex-mobile/lib/features/message/presentation/pages/message_page.dart +++ b/frontend/genex-mobile/lib/features/message/presentation/pages/message_page.dart @@ -6,10 +6,15 @@ import '../../../../shared/widgets/empty_state.dart'; import '../../../../core/services/notification_service.dart'; import '../../../../core/providers/notification_badge_manager.dart'; -/// A8. 消息模块 +/// A8. 消息模块 — 通知 + 公告 /// -/// 通知 + 公告,分类Tab + 消息详情 -/// 接入后端真实 API +/// Tab 0: 全部(用户通知,不过滤类型) +/// Tab 1: 系统(type=SYSTEM) +/// Tab 2: 公告(独立接口 /api/v1/announcements) +/// Tab 3: 活动(type=ACTIVITY) +/// +/// 注意:通知和公告是两套独立的后端接口,标记已读/全部已读 +/// 需要根据当前 Tab 调用对应的接口。 class MessagePage extends StatefulWidget { const MessagePage({super.key}); @@ -22,10 +27,20 @@ class _MessagePageState extends State late TabController _tabController; final NotificationService _notificationService = NotificationService(); - List _notifications = []; + List _items = []; bool _isLoading = false; String? _error; - NotificationType? _currentFilter; + + // 当前是公告 Tab(index=2),需要使用公告 API + bool get _isAnnouncementsTab => _tabController.index == 2; + + // 按 Tab index 对应的通知类型过滤(仅对通知 Tab 有效) + static const _tabTypeFilter = { + 0: null, + 1: NotificationType.system, + 2: null, // 公告 Tab,类型无意义 + 3: NotificationType.activity, + }; @override void initState() { @@ -33,10 +48,10 @@ class _MessagePageState extends State _tabController = TabController(length: 4, vsync: this); _tabController.addListener(() { if (!_tabController.indexIsChanging) { - _onTabChanged(_tabController.index); + _load(); } }); - _loadNotifications(); + _load(); } @override @@ -45,67 +60,72 @@ class _MessagePageState extends State super.dispose(); } - void _onTabChanged(int index) { - switch (index) { - case 0: - _currentFilter = null; - break; - case 1: - _currentFilter = NotificationType.system; - break; - case 2: - _currentFilter = NotificationType.announcement; - break; - case 3: - _currentFilter = NotificationType.activity; - break; - } - _loadNotifications(); - } + // ── 数据加载 ────────────────────────────────────────────────────────────── - Future _loadNotifications() async { + Future _load() async { setState(() { _isLoading = true; _error = null; }); - try { - final response = await _notificationService.getNotifications( - type: _currentFilter, - ); - if (mounted) { - setState(() { - _notifications = response.notifications; - _isLoading = false; - }); + final List result; + if (_isAnnouncementsTab) { + // Tab 2:公告,使用独立的公告接口 + final resp = await _notificationService.getAnnouncements(page: 1, limit: 50); + result = resp.notifications; + } else { + // Tab 0/1/3:用户通知 + final resp = await _notificationService.getNotifications( + type: _tabTypeFilter[_tabController.index], + page: 1, + limit: 50, + ); + result = resp.notifications; } + if (mounted) setState(() { _items = result; _isLoading = false; }); } catch (e) { - if (mounted) { - setState(() { - _error = e.toString(); - _isLoading = false; - }); - } + if (mounted) setState(() { _error = e.toString(); _isLoading = false; }); } } + // ── 操作 ────────────────────────────────────────────────────────────────── + Future _markAllAsRead() async { - final success = await _notificationService.markAllAnnouncementsAsRead(); + final bool success; + if (_isAnnouncementsTab) { + success = await _notificationService.markAllAnnouncementsAsRead(); + } else { + // 通知全部已读(后端有 PUT /api/v1/notifications/read-all) + success = await _notificationService.markAllNotificationsAsRead(); + } if (success) { NotificationBadgeManager().clearCount(); - _loadNotifications(); + _load(); } } Future _markAsRead(NotificationItem item) async { if (item.isRead) return; - final success = await _notificationService.markAsRead(item.id); + final bool success; + if (_isAnnouncementsTab) { + success = await _notificationService.markAnnouncementAsRead(item.id); + } else { + success = await _notificationService.markAsRead(item.id); + } if (success) { NotificationBadgeManager().decrementCount(); - _loadNotifications(); + _load(); } } + void _openDetail(NotificationItem item) { + _markAsRead(item); + // 传递 NotificationItem 让详情页直接渲染,无需二次请求 + Navigator.pushNamed(context, '/message/detail', arguments: item); + } + + // ── Build ───────────────────────────────────────────────────────────────── + @override Widget build(BuildContext context) { return Scaffold( @@ -115,8 +135,10 @@ class _MessagePageState extends State actions: [ TextButton( onPressed: _markAllAsRead, - child: Text(context.t('notification.markAllRead'), - style: AppTypography.labelSmall.copyWith(color: AppColors.primary)), + child: Text( + context.t('notification.markAllRead'), + style: AppTypography.labelSmall.copyWith(color: AppColors.primary), + ), ), ], bottom: TabBar( @@ -135,17 +157,15 @@ class _MessagePageState extends State ? const Center(child: CircularProgressIndicator()) : _error != null ? _buildErrorView() - : _notifications.isEmpty + : _items.isEmpty ? EmptyState.noMessages(context) : RefreshIndicator( - onRefresh: _loadNotifications, + onRefresh: _load, child: ListView.separated( padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _notifications.length, + itemCount: _items.length, separatorBuilder: (_, __) => const Divider(indent: 76), - itemBuilder: (context, index) { - return _buildMessageItem(_notifications[index]); - }, + itemBuilder: (_, i) => _buildItem(_items[i]), ), ), ); @@ -156,11 +176,10 @@ class _MessagePageState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text(context.t('notification.loadFailed'), - style: AppTypography.bodyMedium), + Text(context.t('notification.loadFailed'), style: AppTypography.bodyMedium), const SizedBox(height: 16), ElevatedButton( - onPressed: _loadNotifications, + onPressed: _load, child: Text(context.t('notification.retry')), ), ], @@ -168,7 +187,7 @@ class _MessagePageState extends State ); } - Widget _buildMessageItem(NotificationItem item) { + Widget _buildItem(NotificationItem item) { return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), leading: Container( @@ -183,13 +202,15 @@ class _MessagePageState extends State title: Row( children: [ Expanded( - child: Text(item.title, - style: AppTypography.labelMedium.copyWith( - fontWeight: item.isRead ? FontWeight.w400 : FontWeight.w600, - )), + child: Text( + item.title, + style: AppTypography.labelMedium.copyWith( + fontWeight: item.isRead ? FontWeight.w400 : FontWeight.w600, + ), + ), ), if (item.publishedAt != null) - Text(_formatTime(item.publishedAt!), style: AppTypography.caption), + Text(_formatTime(context, item.publishedAt!), style: AppTypography.caption), ], ), subtitle: Padding( @@ -211,50 +232,39 @@ class _MessagePageState extends State ), ) : null, - onTap: () { - _markAsRead(item); - Navigator.pushNamed(context, '/message/detail'); - }, + onTap: () => _openDetail(item), ); } - String _formatTime(DateTime time) { + // ── 工具方法 ────────────────────────────────────────────────────────────── + + String _formatTime(BuildContext context, DateTime time) { final now = DateTime.now(); final diff = now.difference(time); - if (diff.inMinutes < 1) return '刚刚'; - if (diff.inMinutes < 60) return '${diff.inMinutes}分钟前'; - if (diff.inHours < 24) return '${diff.inHours}小时前'; - if (diff.inDays < 7) return '${diff.inDays}天前'; + if (diff.inMinutes < 1) return context.t('time.justNow'); + if (diff.inMinutes < 60) return context.t('time.minutesAgo').replaceAll('{n}', '${diff.inMinutes}'); + if (diff.inHours < 24) return context.t('time.hoursAgo').replaceAll('{n}', '${diff.inHours}'); + if (diff.inDays < 7) return context.t('time.daysAgo').replaceAll('{n}', '${diff.inDays}'); return '${time.month}/${time.day}'; } IconData _iconData(NotificationType type) { switch (type) { - case NotificationType.system: - return Icons.settings_rounded; - case NotificationType.activity: - return Icons.swap_horiz_rounded; - case NotificationType.reward: - return Icons.card_giftcard_rounded; - case NotificationType.upgrade: - return Icons.system_update_rounded; - case NotificationType.announcement: - return Icons.campaign_rounded; + case NotificationType.system: return Icons.settings_rounded; + case NotificationType.activity: return Icons.swap_horiz_rounded; + case NotificationType.reward: return Icons.card_giftcard_rounded; + case NotificationType.upgrade: return Icons.system_update_rounded; + case NotificationType.announcement: return Icons.campaign_rounded; } } Color _iconColor(NotificationType type) { switch (type) { - case NotificationType.system: - return AppColors.primary; - case NotificationType.activity: - return AppColors.success; - case NotificationType.reward: - return AppColors.warning; - case NotificationType.upgrade: - return AppColors.info; - case NotificationType.announcement: - return AppColors.info; + case NotificationType.system: return AppColors.primary; + case NotificationType.activity: return AppColors.success; + case NotificationType.reward: return AppColors.warning; + case NotificationType.upgrade: return AppColors.info; + case NotificationType.announcement: return AppColors.info; } } } diff --git a/frontend/genex-mobile/pubspec.yaml b/frontend/genex-mobile/pubspec.yaml index f08e30a..ca64a7b 100644 --- a/frontend/genex-mobile/pubspec.yaml +++ b/frontend/genex-mobile/pubspec.yaml @@ -36,6 +36,7 @@ dependencies: flutter_riverpod: ^2.5.1 fpdart: ^1.1.0 connectivity_plus: ^6.0.3 + native_dio_adapter: ^1.5.1 # 让 Dio 在 Android 上走 OkHttp(Java 网络栈),解决 Dart native socket DNS 解析失败问题 dev_dependencies: flutter_test: