From 3f3d613d99fbd38e63434626d1621d8666e848a5 Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 10 Apr 2026 18:50:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=AB=99=E5=86=85?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: 新增 notifications/user_notifications 表迁移及 ORM 模型 - 后端: 实现 schema/repository/service/router 全套通知 API - GET /api/v1/notifications (列表+游标分页) - GET /api/v1/notifications/unread-count - PATCH /api/v1/notifications/{id}/read (幂等) - PATCH /api/v1/notifications/mark-all-read (幂等) - 后端: payload 使用 Pydantic discriminated union (none/open_route/open_url) - 后端: 19 个单元测试全部通过 - Flutter: 通知 feature 完整实现 (models/apis/repositories/bloc/UI) - Flutter: Home 页通知按钮接入真实页面,显示未读 badge - Flutter: 14 个测试全部通过 - 协议文档: notification-inbox-protocol.md 及错误码注册 --- apps/lib/app/app.dart | 15 + .../presentation/screens/home_screen.dart | 49 +- .../data/apis/notification_api.dart | 113 ++ .../data/models/notification_item.dart | 39 + .../data/models/notification_list_result.dart | 32 + .../data/models/notification_payload.dart | 43 + .../repositories/notification_repository.dart | 46 + .../presentation/bloc/notification_bloc.dart | 311 +++++ .../screens/notification_center_screen.dart | 166 +++ .../widgets/notification_list_item.dart | 102 ++ .../notifications/notification_bloc_test.dart | 162 +++ .../notification_payload_test.dart | 57 + .../20260411_0004_add_notifications_tables.py | 170 +++ backend/src/models/__init__.py | 4 + backend/src/models/notification.py | 58 + backend/src/models/user_notification.py | 41 + backend/src/v1/notifications/__init__.py | 1 + backend/src/v1/notifications/dependencies.py | 14 + backend/src/v1/notifications/repository.py | 113 ++ backend/src/v1/notifications/router.py | 117 ++ backend/src/v1/notifications/schemas.py | 67 + backend/src/v1/notifications/service.py | 141 ++ backend/src/v1/router.py | 2 + .../tests/unit/test_notification_service.py | 381 ++++++ docs/plans/notification-system-plan.md | 1206 ++++++++--------- docs/plans/static-notification-sync-plan.md | 484 +++++++ docs/protocols/common/http-error-codes.md | 6 + .../notification-inbox-protocol.md | 192 +++ 28 files changed, 3481 insertions(+), 651 deletions(-) create mode 100644 apps/lib/features/notifications/data/apis/notification_api.dart create mode 100644 apps/lib/features/notifications/data/models/notification_item.dart create mode 100644 apps/lib/features/notifications/data/models/notification_list_result.dart create mode 100644 apps/lib/features/notifications/data/models/notification_payload.dart create mode 100644 apps/lib/features/notifications/data/repositories/notification_repository.dart create mode 100644 apps/lib/features/notifications/presentation/bloc/notification_bloc.dart create mode 100644 apps/lib/features/notifications/presentation/screens/notification_center_screen.dart create mode 100644 apps/lib/features/notifications/presentation/widgets/notification_list_item.dart create mode 100644 apps/test/features/notifications/notification_bloc_test.dart create mode 100644 apps/test/features/notifications/notification_payload_test.dart create mode 100644 backend/alembic/versions/20260411_0004_add_notifications_tables.py create mode 100644 backend/src/models/notification.py create mode 100644 backend/src/models/user_notification.py create mode 100644 backend/src/v1/notifications/__init__.py create mode 100644 backend/src/v1/notifications/dependencies.py create mode 100644 backend/src/v1/notifications/repository.py create mode 100644 backend/src/v1/notifications/router.py create mode 100644 backend/src/v1/notifications/schemas.py create mode 100644 backend/src/v1/notifications/service.py create mode 100644 backend/tests/unit/test_notification_service.py create mode 100644 docs/plans/static-notification-sync-plan.md create mode 100644 docs/protocols/notification/notification-inbox-protocol.md diff --git a/apps/lib/app/app.dart b/apps/lib/app/app.dart index 2b90cf3..f7c4128 100644 --- a/apps/lib/app/app.dart +++ b/apps/lib/app/app.dart @@ -15,6 +15,9 @@ import '../features/auth/presentation/screens/login_screen.dart'; import '../features/divination/data/apis/divination_api.dart'; import '../features/divination/data/models/divination_result.dart'; import '../features/home/presentation/screens/home_screen.dart'; +import '../features/notifications/data/apis/notification_api.dart'; +import '../features/notifications/data/repositories/notification_repository.dart'; +import '../features/notifications/presentation/bloc/notification_bloc.dart'; import '../features/settings/data/apis/profile_api.dart'; import '../features/settings/data/models/profile_settings.dart'; import '../l10n/app_localizations.dart'; @@ -35,6 +38,9 @@ class _EryaoAppState extends State { late final AuthBloc _authBloc; late final DivinationApi _divinationApi; late final ProfileApi _profileApi; + late final NotificationApi _notificationApi; + late final NotificationRepository _notificationRepository; + late final NotificationBloc _notificationBloc; Locale _locale = const Locale('zh'); ProfileSettingsV1 _profileSettings = ProfileSettingsV1.defaultsForLocale( const Locale('zh'), @@ -61,6 +67,11 @@ class _EryaoAppState extends State { final authApi = AuthApi(apiClient: apiClient); _divinationApi = DivinationApi(apiClient: apiClient); _profileApi = ProfileApi(apiClient: apiClient); + _notificationApi = NotificationApi(apiClient: apiClient); + _notificationRepository = NotificationRepositoryImpl( + notificationApi: _notificationApi, + ); + _notificationBloc = NotificationBloc(repository: _notificationRepository); final authRepository = AuthRepositoryImpl( authApi: authApi, sessionStore: _sessionStore, @@ -347,6 +358,7 @@ class _EryaoAppState extends State { @override void dispose() { _authBloc.dispose(); + _notificationBloc.dispose(); super.dispose(); } @@ -415,6 +427,7 @@ class _EryaoAppState extends State { _ensureCreditsLoaded(state.user!.email); _ensureHistoryLoaded(state.user!.email); _refreshProfile(userEmail: state.user!.email); + _notificationBloc.handleEvent(RefreshUnreadCount()); return HomeScreen( account: state.user!.email, sessionStore: _sessionStore, @@ -423,6 +436,8 @@ class _EryaoAppState extends State { historyRecords: _historyRecords, coinBalance: _creditsBalance, divinationApi: _divinationApi, + notificationBloc: _notificationBloc, + notificationRepository: _notificationRepository, onLocaleChanged: _handleInterfaceLanguageChanged, onProfileSettingsChanged: _saveProfileSettings, onSaveProfile: _saveProfile, diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart index 410dc4d..5701333 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -8,6 +8,9 @@ import '../../../divination/presentation/screens/divination_result_screen.dart'; import '../../../divination/data/apis/divination_api.dart'; import '../../../divination/data/models/divination_params.dart'; import '../../../divination/data/models/divination_result.dart'; +import '../../../notifications/data/repositories/notification_repository.dart'; +import '../../../notifications/presentation/bloc/notification_bloc.dart'; +import '../../../notifications/presentation/screens/notification_center_screen.dart'; import '../../../settings/data/models/profile_settings.dart'; import '../../../settings/presentation/screens/settings_screen.dart'; import '../../../../l10n/app_localizations.dart'; @@ -15,8 +18,6 @@ import '../../../../shared/theme/app_color_palette.dart'; import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/widgets/bottom_nav_bar.dart'; import '../../../../shared/widgets/divination/divination_summary_card.dart'; -import '../../../../shared/widgets/toast/toast.dart'; -import '../../../../shared/widgets/toast/toast_type.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({ @@ -28,6 +29,8 @@ class HomeScreen extends StatefulWidget { required this.historyRecords, required this.coinBalance, required this.divinationApi, + required this.notificationBloc, + required this.notificationRepository, required this.onLocaleChanged, required this.onProfileSettingsChanged, required this.onSaveProfile, @@ -45,6 +48,8 @@ class HomeScreen extends StatefulWidget { final List historyRecords; final int coinBalance; final DivinationApi divinationApi; + final NotificationBloc notificationBloc; + final NotificationRepository notificationRepository; final Future Function(String languageTag) onLocaleChanged; final Future Function(ProfileSettingsV1 settings) onProfileSettingsChanged; @@ -108,6 +113,8 @@ class _HomeScreenState extends State { onDivinationCompleted: widget.onDivinationCompleted, onDeleteHistorySession: widget.onDeleteHistorySession, allowVibration: widget.profileSettings.notification.allowVibration, + notificationBloc: widget.notificationBloc, + notificationRepository: widget.notificationRepository, ), _ProfileTab( account: widget.account, @@ -155,6 +162,8 @@ class _HomeTab extends StatelessWidget { required this.onDivinationCompleted, required this.onDeleteHistorySession, required this.allowVibration, + required this.notificationBloc, + required this.notificationRepository, }); final List historyItems; @@ -165,6 +174,8 @@ class _HomeTab extends StatelessWidget { onDivinationCompleted; final Future Function(String threadId) onDeleteHistorySession; final bool allowVibration; + final NotificationBloc notificationBloc; + final NotificationRepository notificationRepository; @override Widget build(BuildContext context) { @@ -194,16 +205,34 @@ class _HomeTab extends StatelessWidget { ), IconButton( onPressed: () { - Toast.show( - context, - l10n.featurePending, - type: ToastType.info, + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => NotificationCenterScreen( + repository: notificationRepository, + ), + ), ); }, - icon: Icon( - Icons.notifications, - color: colors.primary, - size: 28, + icon: ListenableBuilder( + listenable: notificationBloc, + builder: (context, _) { + final count = notificationBloc.state.unreadCount; + if (count > 0) { + return Badge( + label: Text(count > 99 ? '99+' : '$count'), + child: Icon( + Icons.notifications, + color: colors.primary, + size: 28, + ), + ); + } + return Icon( + Icons.notifications, + color: colors.primary, + size: 28, + ); + }, ), tooltip: l10n.notify, ), diff --git a/apps/lib/features/notifications/data/apis/notification_api.dart b/apps/lib/features/notifications/data/apis/notification_api.dart new file mode 100644 index 0000000..05aaff0 --- /dev/null +++ b/apps/lib/features/notifications/data/apis/notification_api.dart @@ -0,0 +1,113 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/logging/logger.dart'; +import '../../../../core/network/api_problem.dart'; +import '../../../../data/network/api_client.dart'; +import '../models/notification_item.dart'; +import '../models/notification_list_result.dart'; + +class NotificationApi { + NotificationApi({required ApiClient apiClient}) : _apiClient = apiClient; + + final ApiClient _apiClient; + final Logger _logger = getLogger('features.notifications.data.apis'); + + Future listNotifications({ + int limit = 20, + String? cursor, + }) async { + final queryParts = ['limit=$limit']; + if (cursor != null) { + queryParts.add('cursor=$cursor'); + } + final path = '/api/v1/notifications?${queryParts.join("&")}'; + + try { + final json = await _apiClient.getJson(path); + final itemsJson = json['items'] as List? ?? []; + final items = itemsJson + .map((e) => parseNotificationItem(e as Map)) + .toList(); + return NotificationListResult( + items: items, + nextCursor: json['nextCursor'] as String?, + hasMore: json['hasMore'] as bool? ?? false, + ); + } on DioException catch (error, stackTrace) { + _logger.error( + message: 'List notifications failed', + error: error, + stackTrace: stackTrace, + ); + throw _mapProblem(error); + } + } + + Future getUnreadCount() async { + try { + final json = await _apiClient.getJson( + '/api/v1/notifications/unread-count', + ); + return json['count'] as int? ?? 0; + } on DioException catch (error, stackTrace) { + _logger.error( + message: 'Get unread count failed', + error: error, + stackTrace: stackTrace, + ); + throw _mapProblem(error); + } + } + + Future markRead({required String notificationId}) async { + try { + final response = await _apiClient.rawDio.patch>( + '/api/v1/notifications/$notificationId/read', + ); + return parseNotificationItem(response.data!); + } on DioException catch (error, stackTrace) { + _logger.error( + message: 'Mark read failed', + error: error, + stackTrace: stackTrace, + ); + throw _mapProblem(error); + } + } + + Future markAllRead() async { + try { + final response = await _apiClient.rawDio.patch>( + '/api/v1/notifications/mark-all-read', + ); + return response.data?['updatedCount'] as int? ?? 0; + } on DioException catch (error, stackTrace) { + _logger.error( + message: 'Mark all read failed', + error: error, + stackTrace: stackTrace, + ); + throw _mapProblem(error); + } + } + + ApiProblem _mapProblem(DioException error) { + final status = error.response?.statusCode ?? 500; + final data = error.response?.data; + + if (data is Map) { + return ApiProblem( + status: status, + title: (data['title'] as String?) ?? 'Request failed', + detail: (data['detail'] as String?) ?? '', + code: data['code'] as String?, + ); + } + + return ApiProblem( + status: status, + title: 'Network error', + detail: error.message ?? 'Request failed', + ); + } +} diff --git a/apps/lib/features/notifications/data/models/notification_item.dart b/apps/lib/features/notifications/data/models/notification_item.dart new file mode 100644 index 0000000..205335d --- /dev/null +++ b/apps/lib/features/notifications/data/models/notification_item.dart @@ -0,0 +1,39 @@ +import 'notification_payload.dart'; + +class NotificationItem { + const NotificationItem({ + required this.id, + required this.notificationId, + required this.type, + required this.title, + required this.body, + required this.payload, + required this.isRead, + required this.createdAt, + this.readAt, + }); + + final String id; + final String notificationId; + final String type; + final String title; + final String body; + final NotificationPayload payload; + final bool isRead; + final DateTime createdAt; + final DateTime? readAt; + + NotificationItem copyWith({bool? isRead, DateTime? readAt}) { + return NotificationItem( + id: id, + notificationId: notificationId, + type: type, + title: title, + body: body, + payload: payload, + isRead: isRead ?? this.isRead, + createdAt: createdAt, + readAt: readAt ?? this.readAt, + ); + } +} diff --git a/apps/lib/features/notifications/data/models/notification_list_result.dart b/apps/lib/features/notifications/data/models/notification_list_result.dart new file mode 100644 index 0000000..40c0bbf --- /dev/null +++ b/apps/lib/features/notifications/data/models/notification_list_result.dart @@ -0,0 +1,32 @@ +import 'notification_payload.dart'; +import 'notification_item.dart'; + +class NotificationListResult { + const NotificationListResult({ + required this.items, + this.nextCursor, + required this.hasMore, + }); + + final List items; + final String? nextCursor; + final bool hasMore; +} + +NotificationItem parseNotificationItem(Map json) { + return NotificationItem( + id: json['id'] as String, + notificationId: json['notificationId'] as String, + type: json['type'] as String, + title: json['title'] as String, + body: json['body'] as String, + payload: parseNotificationPayload( + (json['payload'] as Map?) ?? {}, + ), + isRead: json['isRead'] as bool? ?? false, + createdAt: DateTime.parse(json['createdAt'] as String), + readAt: json['readAt'] != null + ? DateTime.parse(json['readAt'] as String) + : null, + ); +} diff --git a/apps/lib/features/notifications/data/models/notification_payload.dart b/apps/lib/features/notifications/data/models/notification_payload.dart new file mode 100644 index 0000000..02519c5 --- /dev/null +++ b/apps/lib/features/notifications/data/models/notification_payload.dart @@ -0,0 +1,43 @@ +sealed class NotificationPayload { + const NotificationPayload(); +} + +final class NotificationPayloadNone extends NotificationPayload { + const NotificationPayloadNone(); +} + +final class NotificationPayloadRoute extends NotificationPayload { + const NotificationPayloadRoute({ + required this.route, + this.entityId, + this.tab, + }); + + final String route; + final String? entityId; + final String? tab; +} + +final class NotificationPayloadUrl extends NotificationPayload { + const NotificationPayloadUrl({required this.url}); + + final String url; +} + +NotificationPayload parseNotificationPayload(Map json) { + final action = json['action']; + switch (action) { + case 'open_route': + return NotificationPayloadRoute( + route: json['route'] as String? ?? '', + entityId: json['entityId'] as String?, + tab: json['tab'] as String?, + ); + case 'open_url': + return NotificationPayloadUrl(url: json['url'] as String? ?? ''); + case 'none': + return const NotificationPayloadNone(); + default: + return const NotificationPayloadNone(); + } +} diff --git a/apps/lib/features/notifications/data/repositories/notification_repository.dart b/apps/lib/features/notifications/data/repositories/notification_repository.dart new file mode 100644 index 0000000..cc6ed8d --- /dev/null +++ b/apps/lib/features/notifications/data/repositories/notification_repository.dart @@ -0,0 +1,46 @@ +import '../../data/apis/notification_api.dart'; +import '../../data/models/notification_item.dart'; +import '../../data/models/notification_list_result.dart'; + +abstract class NotificationRepository { + Future listNotifications({ + int limit = 20, + String? cursor, + }); + + Future getUnreadCount(); + + Future markRead({required String notificationId}); + + Future markAllRead(); +} + +class NotificationRepositoryImpl implements NotificationRepository { + NotificationRepositoryImpl({required NotificationApi notificationApi}) + : _notificationApi = notificationApi; + + final NotificationApi _notificationApi; + + @override + Future listNotifications({ + int limit = 20, + String? cursor, + }) async { + return _notificationApi.listNotifications(limit: limit, cursor: cursor); + } + + @override + Future getUnreadCount() async { + return _notificationApi.getUnreadCount(); + } + + @override + Future markRead({required String notificationId}) async { + return _notificationApi.markRead(notificationId: notificationId); + } + + @override + Future markAllRead() async { + return _notificationApi.markAllRead(); + } +} diff --git a/apps/lib/features/notifications/presentation/bloc/notification_bloc.dart b/apps/lib/features/notifications/presentation/bloc/notification_bloc.dart new file mode 100644 index 0000000..3710ea2 --- /dev/null +++ b/apps/lib/features/notifications/presentation/bloc/notification_bloc.dart @@ -0,0 +1,311 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../../../../core/logging/logger.dart'; +import '../../data/models/notification_item.dart'; +import '../../data/repositories/notification_repository.dart'; + +enum NotificationStatus { initial, loading, loaded, error } + +class NotificationState { + const NotificationState({ + this.status = NotificationStatus.initial, + this.items = const [], + this.unreadCount = 0, + this.hasMore = false, + this.nextCursor, + this.errorMessage, + }); + + final NotificationStatus status; + final List items; + final int unreadCount; + final bool hasMore; + final String? nextCursor; + final String? errorMessage; + + NotificationState copyWith({ + NotificationStatus? status, + List? items, + int? unreadCount, + bool? hasMore, + String? nextCursor, + String? errorMessage, + }) { + return NotificationState( + status: status ?? this.status, + items: items ?? this.items, + unreadCount: unreadCount ?? this.unreadCount, + hasMore: hasMore ?? this.hasMore, + nextCursor: nextCursor ?? this.nextCursor, + errorMessage: errorMessage ?? this.errorMessage, + ); + } +} + +sealed class NotificationEvent {} + +final class LoadNotifications extends NotificationEvent {} + +final class RefreshNotifications extends NotificationEvent {} + +final class LoadMoreNotifications extends NotificationEvent {} + +final class MarkNotificationRead extends NotificationEvent { + MarkNotificationRead({required this.notificationId}); + final String notificationId; +} + +final class MarkAllNotificationsRead extends NotificationEvent {} + +final class RefreshUnreadCount extends NotificationEvent {} + +final class NotificationCreatedEvent extends NotificationEvent { + NotificationCreatedEvent({required this.item}); + final NotificationItem item; +} + +final class NotificationReadUpdatedEvent extends NotificationEvent { + NotificationReadUpdatedEvent({ + required this.notificationId, + required this.isRead, + }); + final String notificationId; + final bool isRead; +} + +final class NotificationRevokedEvent extends NotificationEvent { + NotificationRevokedEvent({required this.notificationId}); + final String notificationId; +} + +class NotificationBloc extends ChangeNotifier { + NotificationBloc({required NotificationRepository repository}) + : _repository = repository; + + final NotificationRepository _repository; + final Logger _logger = getLogger('features.notifications.bloc'); + NotificationState _state = const NotificationState(); + + NotificationState get state => _state; + + Future handleEvent(NotificationEvent event) async { + switch (event) { + case LoadNotifications(): + await _loadNotifications(); + case RefreshNotifications(): + await _refreshNotifications(); + case LoadMoreNotifications(): + await _loadMore(); + case MarkNotificationRead(): + await _markRead(event.notificationId); + case MarkAllNotificationsRead(): + await _markAllRead(); + case RefreshUnreadCount(): + await _refreshUnreadCount(); + case NotificationCreatedEvent(): + _handleCreated(event.item); + case NotificationReadUpdatedEvent(): + _handleReadUpdated(event.notificationId, event.isRead); + case NotificationRevokedEvent(): + _handleRevoked(event.notificationId); + } + } + + Future _loadNotifications() async { + if (_state.status == NotificationStatus.loading) return; + _state = _state.copyWith(status: NotificationStatus.loading); + notifyListeners(); + + try { + final result = await _repository.listNotifications(limit: 20); + _state = _state.copyWith( + status: NotificationStatus.loaded, + items: result.items, + hasMore: result.hasMore, + nextCursor: result.nextCursor, + ); + notifyListeners(); + } catch (error, stackTrace) { + _logger.error( + message: 'Load notifications failed: ${error.runtimeType}', + error: error, + stackTrace: stackTrace, + ); + _state = _state.copyWith( + status: NotificationStatus.error, + errorMessage: error.toString(), + ); + notifyListeners(); + } + } + + Future _refreshNotifications() async { + try { + final result = await _repository.listNotifications(limit: 20); + _state = _state.copyWith( + status: NotificationStatus.loaded, + items: result.items, + hasMore: result.hasMore, + nextCursor: result.nextCursor, + ); + notifyListeners(); + } catch (error, stackTrace) { + _logger.error( + message: 'Refresh notifications failed: ${error.runtimeType}', + error: error, + stackTrace: stackTrace, + ); + } + } + + Future _loadMore() async { + if (!_state.hasMore || _state.nextCursor == null) return; + + try { + final result = await _repository.listNotifications( + limit: 20, + cursor: _state.nextCursor, + ); + final allItems = [..._state.items, ...result.items]; + _state = _state.copyWith( + items: allItems, + hasMore: result.hasMore, + nextCursor: result.nextCursor, + ); + notifyListeners(); + } catch (error, stackTrace) { + _logger.error( + message: 'Load more notifications failed: ${error.runtimeType}', + error: error, + stackTrace: stackTrace, + ); + } + } + + Future _markRead(String notificationId) async { + final previousItems = _state.items; + final previousCount = _state.unreadCount; + final idx = _state.items.indexWhere((item) => item.id == notificationId); + if (idx == -1) return; + + final wasUnread = !_state.items[idx].isRead; + _state = _state.copyWith( + items: [ + ..._state.items.sublist(0, idx), + _state.items[idx].copyWith(isRead: true), + ..._state.items.sublist(idx + 1), + ], + unreadCount: wasUnread + ? (_state.unreadCount > 0 ? _state.unreadCount - 1 : 0) + : _state.unreadCount, + ); + notifyListeners(); + + try { + await _repository.markRead(notificationId: notificationId); + } catch (error, stackTrace) { + _logger.error( + message: 'Mark read failed: ${error.runtimeType}', + error: error, + stackTrace: stackTrace, + ); + _state = _state.copyWith( + items: previousItems, + unreadCount: previousCount, + ); + notifyListeners(); + } + } + + Future _markAllRead() async { + final previousItems = _state.items; + _state = _state.copyWith( + items: _state.items.map((item) => item.copyWith(isRead: true)).toList(), + unreadCount: 0, + ); + notifyListeners(); + + try { + await _repository.markAllRead(); + } catch (error, stackTrace) { + _logger.error( + message: 'Mark all read failed: ${error.runtimeType}', + error: error, + stackTrace: stackTrace, + ); + _state = _state.copyWith(items: previousItems); + notifyListeners(); + } + } + + Future _refreshUnreadCount() async { + try { + final count = await _repository.getUnreadCount(); + _state = _state.copyWith(unreadCount: count); + notifyListeners(); + } catch (error, stackTrace) { + _logger.error( + message: 'Refresh unread count failed: ${error.runtimeType}', + error: error, + stackTrace: stackTrace, + ); + } + } + + void _handleCreated(NotificationItem item) { + final exists = _state.items.any((i) => i.id == item.id); + if (exists) return; + _state = _state.copyWith( + items: [item, ..._state.items], + unreadCount: _state.unreadCount + (item.isRead ? 0 : 1), + ); + notifyListeners(); + } + + void _handleReadUpdated(String notificationId, bool isRead) { + final idx = _state.items.indexWhere((item) => item.id == notificationId); + if (idx == -1) return; + final wasUnread = !_state.items[idx].isRead; + final nowRead = isRead; + + _state = _state.copyWith( + items: [ + ..._state.items.sublist(0, idx), + _state.items[idx].copyWith(isRead: nowRead), + ..._state.items.sublist(idx + 1), + ], + ); + + if (wasUnread && nowRead) { + _state = _state.copyWith( + unreadCount: _state.unreadCount > 0 ? _state.unreadCount - 1 : 0, + ); + } else if (!wasUnread && !nowRead) { + _state = _state.copyWith(unreadCount: _state.unreadCount + 1); + } + notifyListeners(); + } + + void _handleRevoked(String notificationId) { + final matchingItems = _state.items.where( + (i) => i.notificationId == notificationId, + ); + if (matchingItems.isEmpty) return; + + final item = matchingItems.first; + final wasUnread = !item.isRead; + _state = _state.copyWith( + items: _state.items + .where((i) => i.notificationId != notificationId) + .toList(), + ); + if (wasUnread) { + _state = _state.copyWith( + unreadCount: _state.unreadCount > 0 ? _state.unreadCount - 1 : 0, + ); + } + notifyListeners(); + } +} diff --git a/apps/lib/features/notifications/presentation/screens/notification_center_screen.dart b/apps/lib/features/notifications/presentation/screens/notification_center_screen.dart new file mode 100644 index 0000000..36ac16a --- /dev/null +++ b/apps/lib/features/notifications/presentation/screens/notification_center_screen.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart'; + +import '../../../../shared/theme/design_tokens.dart'; +import '../../data/models/notification_item.dart'; +import '../../data/models/notification_payload.dart'; +import '../../data/repositories/notification_repository.dart'; +import '../bloc/notification_bloc.dart'; +import '../widgets/notification_list_item.dart'; + +class NotificationCenterScreen extends StatefulWidget { + const NotificationCenterScreen({ + super.key, + required this.repository, + this.onNavigateToRoute, + this.onOpenUrl, + }); + + final NotificationRepository repository; + final void Function(String route, {String? entityId, String? tab})? + onNavigateToRoute; + final void Function(String url)? onOpenUrl; + + @override + State createState() => + _NotificationCenterScreenState(); +} + +class _NotificationCenterScreenState extends State { + late NotificationBloc _bloc; + + @override + void initState() { + super.initState(); + _bloc = NotificationBloc(repository: widget.repository); + _bloc.handleEvent(LoadNotifications()); + _bloc.addListener(_onStateChanged); + } + + void _onStateChanged() { + setState(() {}); + } + + @override + void dispose() { + _bloc.removeListener(_onStateChanged); + _bloc.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final state = _bloc.state; + + return Scaffold( + appBar: AppBar( + title: const Text('通知'), + actions: [ + if (state.items.any((item) => !item.isRead)) + TextButton( + onPressed: _onMarkAllRead, + child: Text('全部已读', style: TextStyle(color: colors.primary)), + ), + ], + ), + body: RefreshIndicator( + onRefresh: () => _bloc.handleEvent(RefreshNotifications()), + child: _buildBody(state, colors), + ), + ); + } + + Widget _buildBody(NotificationState state, ColorScheme colors) { + if (state.status == NotificationStatus.loading && state.items.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.status == NotificationStatus.error && state.items.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 48, color: colors.error), + const SizedBox(height: AppSpacing.md), + Text('加载失败', style: TextStyle(color: colors.onSurfaceVariant)), + const SizedBox(height: AppSpacing.sm), + FilledButton( + onPressed: () => _bloc.handleEvent(LoadNotifications()), + child: const Text('重试'), + ), + ], + ), + ); + } + + if (state.items.isEmpty) { + return ListView( + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.5, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.notifications_none_outlined, + size: 64, + color: colors.outline, + ), + const SizedBox(height: AppSpacing.md), + Text( + '暂无通知', + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 16, + ), + ), + ], + ), + ), + ), + ], + ); + } + + return ListView.builder( + itemCount: state.items.length + (state.hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == state.items.length && state.hasMore) { + _bloc.handleEvent(LoadMoreNotifications()); + return const Padding( + padding: EdgeInsets.all(AppSpacing.lg), + child: Center(child: CircularProgressIndicator()), + ); + } + final item = state.items[index]; + return NotificationListItem( + item: item, + onTap: () => _handleNotificationTap(item), + ); + }, + ); + } + + void _handleNotificationTap(NotificationItem item) { + if (!item.isRead) { + _bloc.handleEvent(MarkNotificationRead(notificationId: item.id)); + } + _executePayload(item.payload); + } + + void _executePayload(NotificationPayload payload) { + switch (payload) { + case NotificationPayloadNone(): + break; + case NotificationPayloadRoute(:final route, :final entityId, :final tab): + widget.onNavigateToRoute?.call(route, entityId: entityId, tab: tab); + case NotificationPayloadUrl(:final url): + widget.onOpenUrl?.call(url); + } + } + + void _onMarkAllRead() { + _bloc.handleEvent(MarkAllNotificationsRead()); + } +} diff --git a/apps/lib/features/notifications/presentation/widgets/notification_list_item.dart b/apps/lib/features/notifications/presentation/widgets/notification_list_item.dart new file mode 100644 index 0000000..43cf4db --- /dev/null +++ b/apps/lib/features/notifications/presentation/widgets/notification_list_item.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; + +import '../../../../shared/theme/design_tokens.dart'; +import '../../data/models/notification_item.dart'; + +class NotificationListItem extends StatelessWidget { + const NotificationListItem({ + super.key, + required this.item, + required this.onTap, + }); + + final NotificationItem item; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + decoration: BoxDecoration( + color: item.isRead ? colors.surface : colors.surfaceContainerHighest, + border: Border( + bottom: BorderSide( + color: colors.outlineVariant.withValues(alpha: 0.3), + width: 0.5, + ), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!item.isRead) + Container( + margin: const EdgeInsets.only( + top: AppSpacing.sm, + right: AppSpacing.sm, + ), + width: 8, + height: 8, + decoration: BoxDecoration( + color: colors.primary, + shape: BoxShape.circle, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: textTheme.bodyMedium?.copyWith( + fontWeight: item.isRead + ? FontWeight.normal + : FontWeight.w600, + color: colors.onSurface, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: AppSpacing.xs), + Text( + item.body, + style: textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: AppSpacing.xs), + Text( + _formatTime(item.createdAt), + style: textTheme.labelSmall?.copyWith( + color: colors.outline, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + String _formatTime(DateTime dt) { + final now = DateTime.now(); + final diff = now.difference(dt); + if (diff.inMinutes < 1) return '刚刚'; + if (diff.inHours < 1) return '${diff.inMinutes}分钟前'; + if (diff.inDays < 1) return '${diff.inHours}小时前'; + if (diff.inDays < 30) return '${diff.inDays}天前'; + return '${dt.month}/${dt.day}'; + } +} diff --git a/apps/test/features/notifications/notification_bloc_test.dart b/apps/test/features/notifications/notification_bloc_test.dart new file mode 100644 index 0000000..82769ce --- /dev/null +++ b/apps/test/features/notifications/notification_bloc_test.dart @@ -0,0 +1,162 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:meeyao_qianwen/features/notifications/data/models/notification_item.dart'; +import 'package:meeyao_qianwen/features/notifications/data/models/notification_list_result.dart'; +import 'package:meeyao_qianwen/features/notifications/data/models/notification_payload.dart'; +import 'package:meeyao_qianwen/features/notifications/data/repositories/notification_repository.dart'; +import 'package:meeyao_qianwen/features/notifications/presentation/bloc/notification_bloc.dart'; + +class _FakeNotificationRepository implements NotificationRepository { + final List items = []; + int unreadCount = 0; + int markAllReadCallCount = 0; + + @override + Future listNotifications({ + int limit = 20, + String? cursor, + }) async { + return NotificationListResult( + items: items, + hasMore: false, + nextCursor: null, + ); + } + + @override + Future getUnreadCount() async => unreadCount; + + @override + Future markRead({required String notificationId}) async { + final idx = items.indexWhere((i) => i.id == notificationId); + if (idx == -1) { + throw Exception('Not found'); + } + items[idx] = items[idx].copyWith(isRead: true); + unreadCount = unreadCount > 0 ? unreadCount - 1 : 0; + return items[idx]; + } + + @override + Future markAllRead() async { + markAllReadCallCount++; + final count = unreadCount; + for (int i = 0; i < items.length; i++) { + items[i] = items[i].copyWith(isRead: true); + } + unreadCount = 0; + return count; + } +} + +NotificationItem makeItem({ + String id = 'item-1', + String notificationId = 'notif-1', + bool isRead = false, +}) { + return NotificationItem( + id: id, + notificationId: notificationId, + type: 'system', + title: 'Test', + body: 'Body', + payload: const NotificationPayloadNone(), + isRead: isRead, + createdAt: DateTime(2026, 4, 10), + ); +} + +void main() { + group('NotificationBloc', () { + late _FakeNotificationRepository fakeRepo; + late NotificationBloc bloc; + + setUp(() { + fakeRepo = _FakeNotificationRepository(); + bloc = NotificationBloc(repository: fakeRepo); + }); + + tearDown(() { + bloc.dispose(); + }); + + test('initial state has zero unreadCount', () { + expect(bloc.state.unreadCount, 0); + expect(bloc.state.items, isEmpty); + }); + + test('RefreshUnreadCount updates unreadCount', () async { + fakeRepo.unreadCount = 5; + await bloc.handleEvent(RefreshUnreadCount()); + expect(bloc.state.unreadCount, 5); + }); + + test('MarkNotificationRead marks item as read', () async { + fakeRepo.items.add(makeItem(id: 'n1', isRead: false)); + await bloc.handleEvent(LoadNotifications()); + await bloc.handleEvent(MarkNotificationRead(notificationId: 'n1')); + expect(bloc.state.items.first.isRead, true); + expect(bloc.state.unreadCount, 0); + }); + + test('MarkAllNotificationsRead marks all as read', () async { + fakeRepo.items.addAll([ + makeItem(id: 'n1', isRead: false), + makeItem(id: 'n2', isRead: false), + ]); + fakeRepo.unreadCount = 2; + await bloc.handleEvent(LoadNotifications()); + + await bloc.handleEvent(MarkAllNotificationsRead()); + expect(bloc.state.unreadCount, 0); + expect(bloc.state.items.every((i) => i.isRead), true); + }); + + test( + 'NotificationCreatedEvent adds item and increments unreadCount', + () async { + final item = makeItem(id: 'new-1', isRead: false); + bloc.handleEvent(NotificationCreatedEvent(item: item)); + expect(bloc.state.items.length, 1); + expect(bloc.state.unreadCount, 1); + }, + ); + + test( + 'NotificationCreatedEvent for read item does not increment unreadCount', + () async { + final item = makeItem(id: 'new-1', isRead: true); + bloc.handleEvent(NotificationCreatedEvent(item: item)); + expect(bloc.state.items.length, 1); + expect(bloc.state.unreadCount, 0); + }, + ); + + test( + 'NotificationReadUpdatedEvent updates existing item to read', + () async { + fakeRepo.items.add(makeItem(id: 'n1', isRead: false)); + fakeRepo.unreadCount = 1; + await bloc.handleEvent(LoadNotifications()); + + bloc.handleEvent( + NotificationReadUpdatedEvent(notificationId: 'n1', isRead: true), + ); + expect(bloc.state.items.first.isRead, true); + expect(bloc.state.unreadCount, 0); + }, + ); + + test('NotificationRevokedEvent removes item', () async { + fakeRepo.items.add( + makeItem(id: 'n1', notificationId: 'notif-1', isRead: false), + ); + fakeRepo.unreadCount = 1; + await bloc.handleEvent(LoadNotifications()); + + bloc.handleEvent(NotificationRevokedEvent(notificationId: 'notif-1')); + expect(bloc.state.items, isEmpty); + expect(bloc.state.unreadCount, 0); + }); + }); +} diff --git a/apps/test/features/notifications/notification_payload_test.dart b/apps/test/features/notifications/notification_payload_test.dart new file mode 100644 index 0000000..79ee101 --- /dev/null +++ b/apps/test/features/notifications/notification_payload_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meeyao_qianwen/features/notifications/data/models/notification_payload.dart'; + +void main() { + group('parseNotificationPayload', () { + test('parses none action', () { + final payload = parseNotificationPayload({'action': 'none'}); + expect(payload, isA()); + }); + + test('parses open_route action with all fields', () { + final payload = parseNotificationPayload({ + 'action': 'open_route', + 'route': '/history', + 'entityId': 'abc-123', + 'tab': 'details', + }); + expect(payload, isA()); + final routePayload = payload as NotificationPayloadRoute; + expect(routePayload.route, '/history'); + expect(routePayload.entityId, 'abc-123'); + expect(routePayload.tab, 'details'); + }); + + test('parses open_route action with minimal fields', () { + final payload = parseNotificationPayload({ + 'action': 'open_route', + 'route': '/settings', + }); + expect(payload, isA()); + final routePayload = payload as NotificationPayloadRoute; + expect(routePayload.route, '/settings'); + expect(routePayload.entityId, isNull); + expect(routePayload.tab, isNull); + }); + + test('parses open_url action', () { + final payload = parseNotificationPayload({ + 'action': 'open_url', + 'url': 'https://example.com', + }); + expect(payload, isA()); + final urlPayload = payload as NotificationPayloadUrl; + expect(urlPayload.url, 'https://example.com'); + }); + + test('unknown action defaults to none', () { + final payload = parseNotificationPayload({'action': 'unknown'}); + expect(payload, isA()); + }); + + test('missing action defaults to none', () { + final payload = parseNotificationPayload({}); + expect(payload, isA()); + }); + }); +} diff --git a/backend/alembic/versions/20260411_0004_add_notifications_tables.py b/backend/alembic/versions/20260411_0004_add_notifications_tables.py new file mode 100644 index 0000000..1bab5d4 --- /dev/null +++ b/backend/alembic/versions/20260411_0004_add_notifications_tables.py @@ -0,0 +1,170 @@ +"""add notifications and user_notifications tables + +Revision ID: 20260411_0004 +Revises: 20260411_0003 +Create Date: 2026-04-11 12:00:00 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "20260411_0004" +down_revision: Union[str, Sequence[str], None] = "20260411_0003" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "notifications", + sa.Column( + "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.Column( + "type", + sa.String(length=32), + server_default=sa.text("'system'"), + nullable=False, + ), + sa.Column("title", sa.Text(), nullable=False), + sa.Column("body", sa.Text(), nullable=False), + sa.Column( + "payload", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + sa.Column( + "status", + sa.String(length=16), + server_default=sa.text("'published'"), + nullable=False, + ), + sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint( + "status IN ('draft', 'published', 'revoked')", + name="ck_notifications_status", + ), + sa.CheckConstraint( + "jsonb_typeof(payload) = 'object'", + name="ck_notifications_payload_object", + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_notifications_status_created_at", + "notifications", + ["status", sa.text("created_at DESC")], + ) + op.create_index( + "ix_notifications_published_at", + "notifications", + [sa.text("published_at DESC")], + ) + _enable_rls("notifications") + + op.create_table( + "user_notifications", + sa.Column( + "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False + ), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("notification_id", sa.UUID(), nullable=False), + sa.Column( + "is_read", sa.Boolean(), server_default=sa.text("false"), nullable=False + ), + sa.Column("read_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint( + ["notification_id"], ["notifications.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "user_id", "notification_id", name="uq_user_notifications_user_notification" + ), + ) + op.create_index( + "ix_user_notifications_user_created_at", + "user_notifications", + ["user_id", sa.text("created_at DESC")], + ) + op.create_index( + "ix_user_notifications_user_unread", + "user_notifications", + ["user_id", "is_read"], + ) + _enable_rls("user_notifications") + + +def downgrade() -> None: + _drop_rls("user_notifications") + op.drop_index("ix_user_notifications_user_unread", table_name="user_notifications") + op.drop_index( + "ix_user_notifications_user_created_at", table_name="user_notifications" + ) + op.drop_table("user_notifications") + + _drop_rls("notifications") + op.drop_index("ix_notifications_published_at", table_name="notifications") + op.drop_index("ix_notifications_status_created_at", table_name="notifications") + op.drop_table("notifications") + + +def _enable_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute( + f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" + ) + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + for role in ["anon", "authenticated"]: + op.execute( + f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)" + ) + op.execute( + f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)" + ) + op.execute( + f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)" + ) + + +def _drop_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute( + f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" + ) + op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py index 534bbb4..ba1ba28 100644 --- a/backend/src/models/__init__.py +++ b/backend/src/models/__init__.py @@ -10,7 +10,9 @@ from .points_audit_ledger import PointsAuditLedger from .points_ledger import PointsLedger from .profile import Profile from .register_bonus_claims import RegisterBonusClaims +from .notification import Notification from .system_agents import SystemAgents +from .user_notification import UserNotification from .user_points import UserPoints __all__ = [ @@ -20,10 +22,12 @@ __all__ = [ "InviteCode", "Llm", "LlmFactory", + "Notification", "PointsAuditLedger", "PointsLedger", "Profile", "RegisterBonusClaims", "SystemAgents", + "UserNotification", "UserPoints", ] diff --git a/backend/src/models/notification.py b/backend/src/models/notification.py new file mode 100644 index 0000000..28c8e95 --- /dev/null +++ b/backend/src/models/notification.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import CheckConstraint, DateTime, Index, String, Text, text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, SoftDeleteMixin, TimestampMixin +from core.db.types import json_jsonb + + +class Notification(TimestampMixin, SoftDeleteMixin, Base): + __tablename__ = "notifications" + __table_args__ = ( + CheckConstraint( + "status IN ('draft', 'published', 'revoked')", + name="ck_notifications_status", + ), + CheckConstraint( + "jsonb_typeof(payload) = 'object'", + name="ck_notifications_payload_object", + ), + Index( + "ix_notifications_status_created_at", + "status", + "created_at", + ), + Index( + "ix_notifications_published_at", + "published_at", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + type: Mapped[str] = mapped_column( + String(32), nullable=False, server_default=text("'system'") + ) + title: Mapped[str] = mapped_column(Text, nullable=False) + body: Mapped[str] = mapped_column(Text, nullable=False) + payload: Mapped[dict[str, object]] = mapped_column( + json_jsonb, + nullable=False, + server_default=text("'{}'::jsonb"), + default=dict, + ) + status: Mapped[str] = mapped_column( + String(16), nullable=False, server_default=text("'published'") + ) + published_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + revoked_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) diff --git a/backend/src/models/user_notification.py b/backend/src/models/user_notification.py new file mode 100644 index 0000000..fd9dd3e --- /dev/null +++ b/backend/src/models/user_notification.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, UniqueConstraint, text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, TimestampMixin + + +class UserNotification(TimestampMixin, Base): + __tablename__ = "user_notifications" + __table_args__ = ( + UniqueConstraint( + "user_id", + "notification_id", + name="uq_user_notifications_user_notification", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("auth.users.id", ondelete="CASCADE"), + nullable=False, + ) + notification_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("notifications.id", ondelete="CASCADE"), + nullable=False, + ) + is_read: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default=text("false") + ) + read_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) diff --git a/backend/src/v1/notifications/__init__.py b/backend/src/v1/notifications/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/backend/src/v1/notifications/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/backend/src/v1/notifications/dependencies.py b/backend/src/v1/notifications/dependencies.py new file mode 100644 index 0000000..b172aed --- /dev/null +++ b/backend/src/v1/notifications/dependencies.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from core.db import get_db +from v1.notifications.repository import NotificationRepository +from v1.notifications.service import NotificationService + + +def get_notification_service( + session: AsyncSession = Depends(get_db), +) -> NotificationService: + return NotificationService(repository=NotificationRepository(session)) diff --git a/backend/src/v1/notifications/repository.py b/backend/src/v1/notifications/repository.py new file mode 100644 index 0000000..812e198 --- /dev/null +++ b/backend/src/v1/notifications/repository.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from models.notification import Notification +from models.user_notification import UserNotification + + +class NotificationRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def list_notifications( + self, + *, + user_id: UUID, + limit: int = 20, + cursor: datetime | None = None, + ) -> list[tuple[UserNotification, Notification]]: + stmt = ( + select(UserNotification, Notification) + .join(Notification, UserNotification.notification_id == Notification.id) + .where( + UserNotification.user_id == user_id, + Notification.status == "published", + Notification.deleted_at.is_(None), + ) + .order_by(UserNotification.created_at.desc()) + .limit(limit + 1) + ) + if cursor is not None: + stmt = stmt.where(UserNotification.created_at < cursor) + + rows = (await self._session.execute(stmt)).all() + return [(row[0], row[1]) for row in rows] + + async def get_unread_count(self, *, user_id: UUID) -> int: + stmt = ( + select(func.count()) + .select_from(UserNotification) + .join(Notification, UserNotification.notification_id == Notification.id) + .where( + UserNotification.user_id == user_id, + UserNotification.is_read.is_(False), + Notification.status == "published", + Notification.deleted_at.is_(None), + ) + ) + result = (await self._session.execute(stmt)).scalar_one() + return result + + async def get_user_notification( + self, + *, + user_notification_id: UUID, + user_id: UUID, + ) -> tuple[UserNotification, Notification] | None: + stmt = ( + select(UserNotification, Notification) + .join(Notification, UserNotification.notification_id == Notification.id) + .where( + UserNotification.id == user_notification_id, + UserNotification.user_id == user_id, + Notification.status == "published", + Notification.deleted_at.is_(None), + ) + ) + row = (await self._session.execute(stmt)).first() + if row is None: + return None + return (row[0], row[1]) + + async def mark_read(self, *, user_notification_id: UUID, user_id: UUID) -> bool: + stmt = select(UserNotification).where( + UserNotification.id == user_notification_id, + UserNotification.user_id == user_id, + UserNotification.is_read.is_(False), + ) + un = (await self._session.execute(stmt)).scalar_one_or_none() + if un is None: + return False + un.is_read = True + un.read_at = datetime.now() + await self._session.flush() + return True + + async def mark_all_read(self, *, user_id: UUID) -> int: + un_ids_stmt = ( + select(UserNotification.id) + .join(Notification, UserNotification.notification_id == Notification.id) + .where( + UserNotification.user_id == user_id, + UserNotification.is_read.is_(False), + Notification.status == "published", + Notification.deleted_at.is_(None), + ) + ) + un_ids = list((await self._session.execute(un_ids_stmt)).scalars().all()) + if not un_ids: + return 0 + count = len(un_ids) + stmt = ( + update(UserNotification) + .where(UserNotification.id.in_(un_ids)) + .values(is_read=True, read_at=func.now()) + ) + await self._session.execute(stmt) + await self._session.flush() + return count diff --git a/backend/src/v1/notifications/router.py b/backend/src/v1/notifications/router.py new file mode 100644 index 0000000..63f1953 --- /dev/null +++ b/backend/src/v1/notifications/router.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Query + +from core.auth.models import CurrentUser +from v1.notifications.dependencies import get_notification_service +from v1.notifications.schemas import ( + MarkAllReadResponse, + NotificationItemResponse, + NotificationListResponse, + UnreadCountResponse, +) +from v1.notifications.service import NotificationService +from v1.users.dependencies import get_current_user + +router = APIRouter(prefix="/notifications", tags=["notifications"]) + + +@router.get("", response_model=NotificationListResponse) +async def list_notifications( + service: Annotated[NotificationService, Depends(get_notification_service)], + current_user: Annotated[CurrentUser, Depends(get_current_user)], + limit: int = Query(default=20, ge=1, le=50), + cursor: str | None = Query(default=None), +) -> NotificationListResponse: + from datetime import datetime + + parsed_cursor = None + if cursor is not None: + try: + parsed_cursor = datetime.fromisoformat(cursor.replace("Z", "+00:00")) + except (ValueError, AttributeError): + parsed_cursor = None + + result = await service.list_notifications( + user_id=current_user.id, + limit=limit, + cursor=parsed_cursor, + ) + items = [] + for item in result.items: + items.append( + NotificationItemResponse( + id=str(item.id), + notificationId=str(item.notification_id), + type=item.type, + title=item.title, + body=item.body, + payload=item.payload, + isRead=item.is_read, + readAt=item.read_at, + createdAt=item.created_at, + ) + ) + return NotificationListResponse( + items=items, + nextCursor=result.next_cursor.isoformat() if result.next_cursor else None, + hasMore=result.has_more, + ) + + +@router.get("/unread-count", response_model=UnreadCountResponse) +async def get_unread_count( + service: Annotated[NotificationService, Depends(get_notification_service)], + current_user: Annotated[CurrentUser, Depends(get_current_user)], +) -> UnreadCountResponse: + count = await service.get_unread_count(user_id=current_user.id) + return UnreadCountResponse(count=count) + + +@router.patch("/{notification_id}/read", response_model=NotificationItemResponse) +async def mark_notification_read( + notification_id: str, + service: Annotated[NotificationService, Depends(get_notification_service)], + current_user: Annotated[CurrentUser, Depends(get_current_user)], +) -> NotificationItemResponse: + from uuid import UUID + + try: + uid = UUID(notification_id) + except ValueError: + from core.http.errors import ApiProblemError, problem_payload + + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="NOTIFICATION_NOT_FOUND", + detail="Notification not found or not owned by current user", + ), + ) + + item = await service.mark_read( + user_notification_id=uid, + user_id=current_user.id, + ) + return NotificationItemResponse( + id=str(item.id), + notificationId=str(item.notification_id), + type=item.type, + title=item.title, + body=item.body, + payload=item.payload, + isRead=item.is_read, + readAt=item.read_at, + createdAt=item.created_at, + ) + + +@router.patch("/mark-all-read", response_model=MarkAllReadResponse) +async def mark_all_read( + service: Annotated[NotificationService, Depends(get_notification_service)], + current_user: Annotated[CurrentUser, Depends(get_current_user)], +) -> MarkAllReadResponse: + updated_count = await service.mark_all_read(user_id=current_user.id) + return MarkAllReadResponse(updatedCount=updated_count) diff --git a/backend/src/v1/notifications/schemas.py b/backend/src/v1/notifications/schemas.py new file mode 100644 index 0000000..5907c95 --- /dev/null +++ b/backend/src/v1/notifications/schemas.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal, Union + +from pydantic import BaseModel, ConfigDict, Field + + +class NotificationPayloadNone(BaseModel): + model_config = ConfigDict(extra="forbid") + + action: Literal["none"] + + +class NotificationPayloadRoute(BaseModel): + model_config = ConfigDict(extra="forbid") + + action: Literal["open_route"] + route: str = Field(max_length=200) + entity_id: str | None = Field(default=None, max_length=64) + tab: str | None = Field(default=None, max_length=32) + + +class NotificationPayloadUrl(BaseModel): + model_config = ConfigDict(extra="forbid") + + action: Literal["open_url"] + url: str = Field(max_length=500) + + +NotificationPayload = Union[ + NotificationPayloadNone, NotificationPayloadRoute, NotificationPayloadUrl +] + + +class NotificationItemResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) + + id: str + notification_id: str = Field(alias="notificationId") + type: str + title: str + body: str + payload: NotificationPayload + is_read: bool = Field(alias="isRead") + read_at: datetime | None = Field(alias="readAt", default=None) + created_at: datetime = Field(alias="createdAt") + + +class NotificationListResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) + + items: list[NotificationItemResponse] + next_cursor: str | None = Field(alias="nextCursor", default=None) + has_more: bool = Field(alias="hasMore", default=False) + + +class UnreadCountResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + count: int = Field(ge=0) + + +class MarkAllReadResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) + + updated_count: int = Field(alias="updatedCount", ge=0) diff --git a/backend/src/v1/notifications/service.py b/backend/src/v1/notifications/service.py new file mode 100644 index 0000000..ed8cc11 --- /dev/null +++ b/backend/src/v1/notifications/service.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from core.http.errors import ApiProblemError, problem_payload +from v1.notifications.repository import NotificationRepository +from v1.notifications.schemas import ( + NotificationPayloadNone, + NotificationPayloadRoute, + NotificationPayloadUrl, + NotificationPayload, +) + + +@dataclass(frozen=True) +class NotificationListItem: + id: UUID + notification_id: UUID + type: str + title: str + body: str + payload: NotificationPayload + is_read: bool + read_at: datetime | None + created_at: datetime + + +@dataclass(frozen=True) +class NotificationListResult: + items: list[NotificationListItem] + next_cursor: datetime | None + has_more: bool + + +class NotificationService: + def __init__(self, repository: NotificationRepository) -> None: + self._repository = repository + + async def list_notifications( + self, + *, + user_id: UUID, + limit: int = 20, + cursor: datetime | None = None, + ) -> NotificationListResult: + actual_limit = min(limit, 50) + rows = await self._repository.list_notifications( + user_id=user_id, + limit=actual_limit + 1, + cursor=cursor, + ) + has_more = len(rows) > actual_limit + items = rows[:actual_limit] + next_cursor = None + if has_more and items: + next_cursor = items[-1][0].created_at + + list_items = [] + for un, n in items: + payload = _parse_payload(n.payload) + list_items.append( + NotificationListItem( + id=un.id, + notification_id=n.id, + type=n.type, + title=n.title, + body=n.body, + payload=payload, + is_read=un.is_read, + read_at=un.read_at, + created_at=un.created_at, + ) + ) + return NotificationListResult( + items=list_items, + next_cursor=next_cursor, + has_more=has_more, + ) + + async def get_unread_count(self, *, user_id: UUID) -> int: + return await self._repository.get_unread_count(user_id=user_id) + + async def mark_read( + self, *, user_notification_id: UUID, user_id: UUID + ) -> NotificationListItem: + result = await self._repository.get_user_notification( + user_notification_id=user_notification_id, + user_id=user_id, + ) + if result is None: + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="NOTIFICATION_NOT_FOUND", + detail="Notification not found or not owned by current user", + ), + ) + un, n = result + if not un.is_read: + await self._repository.mark_read( + user_notification_id=user_notification_id, + user_id=user_id, + ) + payload = _parse_payload(n.payload) + return NotificationListItem( + id=un.id, + notification_id=n.id, + type=n.type, + title=n.title, + body=n.body, + payload=payload, + is_read=True, + read_at=un.read_at or datetime.now(), + created_at=un.created_at, + ) + + async def mark_all_read(self, *, user_id: UUID) -> int: + return await self._repository.mark_all_read(user_id=user_id) + + +def _parse_payload(raw: dict[str, object]) -> NotificationPayload: + action = raw.get("action") + if action == "none": + return NotificationPayloadNone(action="none") + if action == "open_route": + return NotificationPayloadRoute( + action="open_route", + route=str(raw.get("route", "")), + entity_id=str(raw["entity_id"]) + if "entity_id" in raw and raw["entity_id"] is not None + else None, + tab=str(raw["tab"]) if "tab" in raw and raw["tab"] is not None else None, + ) + if action == "open_url": + return NotificationPayloadUrl( + action="open_url", + url=str(raw.get("url", "")), + ) + return NotificationPayloadNone(action="none") diff --git a/backend/src/v1/router.py b/backend/src/v1/router.py index daa77e6..00a775a 100644 --- a/backend/src/v1/router.py +++ b/backend/src/v1/router.py @@ -4,6 +4,7 @@ from fastapi import APIRouter from v1.agent.router import router as agent_router from v1.auth.router import router as auth_router +from v1.notifications.router import router as notifications_router from v1.points.router import router as points_router from v1.users.router import router as users_router @@ -11,5 +12,6 @@ from v1.users.router import router as users_router router = APIRouter(prefix="/api/v1") router.include_router(auth_router) router.include_router(agent_router) +router.include_router(notifications_router) router.include_router(points_router) router.include_router(users_router) diff --git a/backend/tests/unit/test_notification_service.py b/backend/tests/unit/test_notification_service.py new file mode 100644 index 0000000..4fc5e6a --- /dev/null +++ b/backend/tests/unit/test_notification_service.py @@ -0,0 +1,381 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +import pytest + +from v1.notifications.service import NotificationService, _parse_payload +from v1.notifications.schemas import ( + NotificationPayloadNone, + NotificationPayloadRoute, + NotificationPayloadUrl, +) +from core.http.errors import ApiProblemError + + +class _FakeUserNotification: + def __init__( + self, + *, + id: UUID, + user_id: UUID, + notification_id: UUID, + is_read: bool = False, + read_at: datetime | None = None, + created_at: datetime | None = None, + ): + self.id = id + self.user_id = user_id + self.notification_id = notification_id + self.is_read = is_read + self.read_at = read_at + self.created_at = created_at or datetime.now() + + +class _FakeNotification: + def __init__( + self, + *, + id: UUID, + type: str = "system", + title: str = "Test", + body: str = "Test body", + payload: dict | None = None, + status: str = "published", + deleted_at: datetime | None = None, + created_at: datetime | None = None, + ): + self.id = id + self.type = type + self.title = title + self.body = body + self.payload = payload or {"action": "none"} + self.status = status + self.deleted_at = deleted_at + self.created_at = created_at or datetime.now() + + +class _FakeNotificationRepository: + def __init__(self) -> None: + self._items: list[tuple[_FakeUserNotification, _FakeNotification]] = [] + self._mark_read_ids: list[UUID] = [] + self._mark_all_read_user_ids: list[UUID] = [] + + def add_item(self, un: _FakeUserNotification, n: _FakeNotification) -> None: + self._items.append((un, n)) + + async def list_notifications( + self, + *, + user_id: UUID, + limit: int = 20, + cursor: datetime | None = None, + ) -> list[tuple[_FakeUserNotification, _FakeNotification]]: + user_items = [ + (un, n) + for un, n in self._items + if un.user_id == user_id + and n.status == "published" + and n.deleted_at is None + ] + if cursor is not None: + user_items = [(un, n) for un, n in user_items if un.created_at < cursor] + user_items.sort(key=lambda x: x[0].created_at, reverse=True) + return user_items[:limit] + + async def get_unread_count(self, *, user_id: UUID) -> int: + return sum( + 1 + for un, n in self._items + if un.user_id == user_id + and not un.is_read + and n.status == "published" + and n.deleted_at is None + ) + + async def get_user_notification( + self, + *, + user_notification_id: UUID, + user_id: UUID, + ) -> tuple[_FakeUserNotification, _FakeNotification] | None: + for un, n in self._items: + if un.id == user_notification_id and un.user_id == user_id: + return (un, n) + return None + + async def mark_read(self, *, user_notification_id: UUID, user_id: UUID) -> bool: + self._mark_read_ids.append(user_notification_id) + for un, n in self._items: + if un.id == user_notification_id and un.user_id == user_id: + un.is_read = True + un.read_at = datetime.now() + return True + return False + + async def mark_all_read(self, *, user_id: UUID) -> int: + self._mark_all_read_user_ids.append(user_id) + count = 0 + for un, n in self._items: + if ( + un.user_id == user_id + and not un.is_read + and n.status == "published" + and n.deleted_at is None + ): + un.is_read = True + un.read_at = datetime.now() + count += 1 + return count + + +@pytest.fixture +def fake_repo() -> _FakeNotificationRepository: + return _FakeNotificationRepository() + + +@pytest.fixture +def service(fake_repo: _FakeNotificationRepository) -> NotificationService: + return NotificationService(repository=fake_repo) # type: ignore[arg-type] + + +USER_A = uuid4() +USER_B = uuid4() + + +def _make_notification( + *, + user_id: UUID, + notification_id: UUID | None = None, + is_read: bool = False, + read_at: datetime | None = None, + title: str = "Test", + body: str = "Test body", + payload: dict | None = None, + status: str = "published", + deleted_at: datetime | None = None, +) -> tuple[_FakeUserNotification, _FakeNotification]: + nid = notification_id or uuid4() + unid = uuid4() + n = _FakeNotification( + id=nid, + title=title, + body=body, + payload=payload, + status=status, + deleted_at=deleted_at, + ) + un = _FakeUserNotification( + id=unid, + user_id=user_id, + notification_id=nid, + is_read=is_read, + read_at=read_at, + ) + return un, n + + +class TestListNotifications: + @pytest.mark.asyncio + async def test_returns_only_user_a_notifications( + self, service: NotificationService, fake_repo: _FakeNotificationRepository + ): + un_a, n_a = _make_notification(user_id=USER_A, title="A1") + un_b, n_b = _make_notification(user_id=USER_B, title="B1") + fake_repo.add_item(un_a, n_a) + fake_repo.add_item(un_b, n_b) + + result = await service.list_notifications(user_id=USER_A) + assert len(result.items) == 1 + assert result.items[0].title == "A1" + + @pytest.mark.asyncio + async def test_excludes_revoked_notifications( + self, service: NotificationService, fake_repo: _FakeNotificationRepository + ): + un, n = _make_notification(user_id=USER_A, status="revoked") + fake_repo.add_item(un, n) + + result = await service.list_notifications(user_id=USER_A) + assert len(result.items) == 0 + + @pytest.mark.asyncio + async def test_excludes_deleted_notifications( + self, service: NotificationService, fake_repo: _FakeNotificationRepository + ): + un, n = _make_notification(user_id=USER_A, deleted_at=datetime.now()) + fake_repo.add_item(un, n) + + result = await service.list_notifications(user_id=USER_A) + assert len(result.items) == 0 + + @pytest.mark.asyncio + async def test_pagination_has_more( + self, service: NotificationService, fake_repo: _FakeNotificationRepository + ): + for i in range(3): + un, n = _make_notification(user_id=USER_A, title=f"N{i}") + fake_repo.add_item(un, n) + + result = await service.list_notifications(user_id=USER_A, limit=2) + assert len(result.items) == 2 + assert result.has_more is True + assert result.next_cursor is not None + + +class TestUnreadCount: + @pytest.mark.asyncio + async def test_counts_unread_only( + self, service: NotificationService, fake_repo: _FakeNotificationRepository + ): + un_read, n_read = _make_notification(user_id=USER_A, is_read=True) + un_unread, n_unread = _make_notification(user_id=USER_A, is_read=False) + fake_repo.add_item(un_read, n_read) + fake_repo.add_item(un_unread, n_unread) + + count = await service.get_unread_count(user_id=USER_A) + assert count == 1 + + @pytest.mark.asyncio + async def test_excludes_revoked_from_count( + self, service: NotificationService, fake_repo: _FakeNotificationRepository + ): + un, n = _make_notification(user_id=USER_A, status="revoked", is_read=False) + fake_repo.add_item(un, n) + + count = await service.get_unread_count(user_id=USER_A) + assert count == 0 + + @pytest.mark.asyncio + async def test_user_b_unread_not_counted_for_user_a( + self, service: NotificationService, fake_repo: _FakeNotificationRepository + ): + un, n = _make_notification(user_id=USER_B, is_read=False) + fake_repo.add_item(un, n) + + count = await service.get_unread_count(user_id=USER_A) + assert count == 0 + + +class TestMarkRead: + @pytest.mark.asyncio + async def test_mark_read_success( + self, service: NotificationService, fake_repo: _FakeNotificationRepository + ): + un, n = _make_notification(user_id=USER_A, is_read=False) + fake_repo.add_item(un, n) + + result = await service.mark_read(user_notification_id=un.id, user_id=USER_A) + assert result.is_read is True + + @pytest.mark.asyncio + async def test_mark_read_idempotent( + self, service: NotificationService, fake_repo: _FakeNotificationRepository + ): + now = datetime.now() + un, n = _make_notification(user_id=USER_A, is_read=True, read_at=now) + fake_repo.add_item(un, n) + + result = await service.mark_read(user_notification_id=un.id, user_id=USER_A) + assert result.is_read is True + + @pytest.mark.asyncio + async def test_mark_read_wrong_user_raises_404( + self, service: NotificationService, fake_repo: _FakeNotificationRepository + ): + un, n = _make_notification(user_id=USER_A) + fake_repo.add_item(un, n) + + with pytest.raises(ApiProblemError) as exc_info: + await service.mark_read(user_notification_id=un.id, user_id=USER_B) + assert exc_info.value.status_code == 404 + assert exc_info.value.code == "NOTIFICATION_NOT_FOUND" + + +class TestMarkAllRead: + @pytest.mark.asyncio + async def test_marks_all_unread_as_read( + self, service: NotificationService, fake_repo: _FakeNotificationRepository + ): + un1, n1 = _make_notification(user_id=USER_A, is_read=False) + un2, n2 = _make_notification(user_id=USER_A, is_read=False) + fake_repo.add_item(un1, n1) + fake_repo.add_item(un2, n2) + + updated = await service.mark_all_read(user_id=USER_A) + assert updated == 2 + + @pytest.mark.asyncio + async def test_idempotent_when_all_read( + self, service: NotificationService, fake_repo: _FakeNotificationRepository + ): + un, n = _make_notification(user_id=USER_A, is_read=True) + fake_repo.add_item(un, n) + + updated = await service.mark_all_read(user_id=USER_A) + assert updated == 0 + + @pytest.mark.asyncio + async def test_does_not_affect_other_user( + self, service: NotificationService, fake_repo: _FakeNotificationRepository + ): + un_a, n_a = _make_notification(user_id=USER_A, is_read=False) + un_b, n_b = _make_notification(user_id=USER_B, is_read=False) + fake_repo.add_item(un_a, n_a) + fake_repo.add_item(un_b, n_b) + + updated = await service.mark_all_read(user_id=USER_A) + assert updated == 1 + assert un_b.is_read is False + + +class TestParsePayload: + def test_none_action(self): + payload = _parse_payload({"action": "none"}) + assert isinstance(payload, NotificationPayloadNone) + assert payload.action == "none" + + def test_open_route_action(self): + payload = _parse_payload( + { + "action": "open_route", + "route": "/history", + "entity_id": "abc-123", + "tab": "details", + } + ) + assert isinstance(payload, NotificationPayloadRoute) + assert payload.route == "/history" + assert payload.entity_id == "abc-123" + assert payload.tab == "details" + + def test_open_url_action(self): + payload = _parse_payload( + { + "action": "open_url", + "url": "https://example.com", + } + ) + assert isinstance(payload, NotificationPayloadUrl) + assert payload.url == "https://example.com" + + def test_unknown_action_defaults_to_none(self): + payload = _parse_payload({"action": "unknown"}) + assert isinstance(payload, NotificationPayloadNone) + + def test_missing_action_defaults_to_none(self): + payload = _parse_payload({}) + assert isinstance(payload, NotificationPayloadNone) + + def test_open_route_minimal(self): + payload = _parse_payload( + { + "action": "open_route", + "route": "/settings", + } + ) + assert isinstance(payload, NotificationPayloadRoute) + assert payload.route == "/settings" + assert payload.entity_id is None + assert payload.tab is None diff --git a/docs/plans/notification-system-plan.md b/docs/plans/notification-system-plan.md index 9142c1a..c017609 100644 --- a/docs/plans/notification-system-plan.md +++ b/docs/plans/notification-system-plan.md @@ -1,784 +1,708 @@ -# 通知系统实现方案 +# 通知系统计划 -> 创建时间:2026-04-10 -> 状态:已评审 +> 更新时间:2026-04-10 +> 状态:最终执行版 -## 1. 需求理解 +## 1. 目标 -### 1.1 核心问题 -当前项目(觅爻签问 App)没有通知系统,需要从零建设。核心需求包括: -- 通知中心(通知列表/收件箱) -- 用户通知存储与读取 -- 已读/已看状态追踪 -- 系统推送触达(APNs/FCM) -- 前台实时通知同步 -- 新版本通知、活动通知等业务通知类型 +本阶段实现最小可用的站内通知系统,满足以下能力: -### 1.2 通知类型区分 +- 系统向用户投递站内通知 +- 用户在 App 内查看通知列表 +- 用户查看通知内容并标记已读 +- 首页复用现有通知按钮作为入口 +- 首页显示未读 badge,并随数据变化自动更新 +- App 前台打开时,新通知自动出现 +- 支持通知主记录的撤销和统一删除 -| 类型 | 说明 | 当前状态 | -|------|------|----------| -| 应用内通知记录 | 存储在 DB 的通知数据 | **不存在** | -| 系统推送通知 | 通过 APNs/FCM 发送 | **不存在** | -| 前台实时同步 | App 打开时实时拉取 | **不存在** | -| 本地通知 | App 本地触发的通知 | **不存在** | -| 已看状态 | 用户是否打开过通知详情 | **未设计** | -| 已读状态 | 用户是否标记为已读 | **未设计** | -| 推送触达 | 消息是否成功送达设备 | **未设计** | - -### 1.3 系统边界 - -``` -┌─────────────────────────────────────────────────────────┐ -│ Flutter App │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ -│ │ 本地通知 │ │ 推送接收 │ │ 通知中心 │ │ Badge │ │ -│ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │ -└───────────────┬─────────────────────┬───────────────────┘ - │ │ - │ REST API │ Supabase Realtime - ▼ ▼ -┌───────────────────────────────────────────────────────────┐ -│ Backend (FastAPI) │ -│ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │ -│ │ 通知写入 │ │ Fanout │ │ 推送发送 (APNs/FCM)│ │ -│ └────────────┘ └────────────┘ └────────────────────┘ │ -└───────────────┬─────────────────────┬───────────────────────┘ - │ │ - ▼ ▼ -┌─────────────────────────┐ ┌─────────────────────────────┐ -│ PostgreSQL (Supabase) │ │ Redis / Supabase │ -│ ┌──────────────────┐ │ │ Realtime │ -│ │ notifications │ │ │ ┌─────────────────────┐ │ -│ │ notification_ │ │ │ │ user:{id}:notif:new │ │ -│ │ receipts │ │ │ └─────────────────────┘ │ -│ │ user_push_ │ │ └─────────────────────────────┘ -│ │ devices │ │ -│ └──────────────────┘ │ -└─────────────────────────┘ - │ - ▼ -┌─────────────────────────┐ -│ Push Provider │ -│ (APNs / FCM / 备用) │ -└─────────────────────────┘ -``` +本阶段不实现系统级离线推送。 --- -## 2. 现有代码调研结果 +## 2. 范围 -### 2.1 已发现的相关模块 +### 2.1 In Scope -#### Flutter 端 +- 站内通知 inbox +- `notifications` 主表管理通知内容和生命周期 +- `user_notifications` 记录用户接收关系和已读状态 +- 通知列表 +- 未读数 +- 单条已读 +- 全部已读 +- 前台 Realtime 增量同步 +- 撤销和统一删除在用户侧生效 -| 路径 | 说明 | -|------|------| -| `apps/lib/features/settings/presentation/screens/privacy_notification_settings_screen.dart` | 通知设置页面(占位 UI) | -| `apps/lib/features/settings/data/models/profile_settings.dart` | `NotificationSettings`(`allowNotifications`, `allowVibration`) | -| `apps/lib/features/home/presentation/screens/home_screen.dart:204` | 通知图标(点击显示 `featurePending`) | +### 2.2 Out of Scope -#### 后端 - -| 路径 | 说明 | -|------|------| -| `backend/src/schemas/shared/user.py:44` | `NotificationSettings` schema | -| `backend/src/v1/users/service.py` | 用户服务(含 settings 更新) | -| `backend/src/v1/users/router.py` | 用户路由 | -| `backend/src/services/base/supabase.py` | Supabase 服务封装 | -| `backend/src/core/config/settings.py` | 配置管理 | - -#### 数据库迁移 - -| 文件 | 说明 | -|------|------| -| `backend/alembic/versions/20260403_0002_user_points_chat_schema.py` | profiles, sessions, messages 表 | -| `backend/alembic/versions/20260407_0001_update_notification_settings.py` | 通知设置默认值 | - -### 2.2 已有能力 - -1. **Profile + Settings 系统**:`profiles.settings` 是 JSONB,可扩展存储通知偏好 -2. **用户认证**:Supabase Auth 完整 -3. **数据库迁移框架**:Alembic 已建立 -4. **后台任务**:Taskiq (基于 Redis) -5. **日志系统**:已集成 structlog -6. **API 模式**:Pydantic schemas, RFC7807 错误格式 - -### 2.3 当前缺口 - -| 缺口 | 说明 | -|------|------| -| 通知数据模型 | 无 `notifications` 表 | -| 推送设备管理 | 无 `user_push_devices` 表 | -| 通知状态追踪 | 无 `user_notifications` 状态字段体系 | -| 推送服务集成 | 无 APNs/FCM/备用推送集成 | -| Flutter 通知中心 | 无通知列表页面 | -| Flutter 推送接收 | 无 firebase_messaging 等依赖 | -| Supabase Realtime | 未用于通知同步 | -| 未读数 Badge | 未实现 | - -### 2.4 潜在冲突或风险 - -1. **AGENTS.md 约束**:`apps/AGENTS.md` 明确要求通知相关代码放在 `core/notification/` 和 `shared/widgets/notification/`,需要遵循 -2. **现有 Settings 结构**:`NotificationSettings` 只有两个布尔字段,未来需要扩展 -3. **数据库 JSONB 查询**:`profiles.settings` 使用 GIN 索引,但通知列表不适合放 JSONB +- APNs / FCM 离线推送 +- 设备 token 注册与管理 +- 推送送达率、失败重试、DLQ +- `seen/opened/provider_ack/push_state` +- 通知模板后台 +- 复杂批量 fanout 系统 +- 用户侧单条删除、归档、撤回 +- 本地通知调度 --- -## 3. 当前架构判断 +## 3. 现有代码基线 -### 3.1 推荐架构:混合推送 + 实时同步(Broadcast 主通道) +实现必须基于当前仓库结构: -**推荐方案:Supabase Realtime Broadcast(前台)+ APNs/FCM(后台/离线)** +后端: -**原因**: -1. App 打开时(前台):使用 Broadcast 推送状态变化,避免直接依赖 `postgres_changes` 在高并发下的 RLS 扫描压力 -2. App 关闭时(后台/离线):通过 APNs/FCM 触达设备 -3. 两条链路统一写入 `user_notifications`,状态由服务端收敛,客户端只上报回执 +- 用户资料与设置接口已存在 +- 通知偏好存于 `profiles.settings.notification` +- ORM 基类位于 `backend/src/core/db/base.py` + - `Base` + - `TimestampMixin` + - `SoftDeleteMixin` -**不适合当前项目的方案**: +Flutter: -| 方案 | 不适合原因 | -|------|------------| -| 纯轮询 | 电量/服务器压力大,不优雅 | -| WebSocket 直连 | 需要自己维护连接,复杂度高 | -| 仅本地通知 | 无法触达离线用户 | -| 仅 APNs/FCM | 无法查看历史通知列表 | +- 首页通知入口位于 `apps/lib/features/home/presentation/screens/home_screen.dart` +- 当前点击行为是 `featurePending` +- App 顶层状态由 `apps/lib/app/app.dart` 持有并下传 +- 现有数据层模式是 `data/apis` + `data/repositories` +- 现有状态管理明确证据是 `ChangeNotifier` 与页面级 `setState` +- 现有导航模式是 `Navigator.of(context).push(MaterialPageRoute(...))` +- 现有事件流解析参考在 `features/divination/data/apis/divination_api.dart::streamEvents` -### 3.2 职责划分 - -| 组件 | 职责 | -|------|------| -| `notifications` 表 | 存储通知内容,永久记录 | -| `user_notifications` 表 | 追踪每个用户对每条通知的状态 | -| `notification_push_attempts` 表 | 追踪推送每次尝试和失败原因 | -| `user_push_devices` 表 | 存储设备 token | -| Supabase Realtime | 前台实时通知同步 | -| APNs/FCM | 后台/离线推送 | +实现时优先复用这些模式,不引入新的全局前端架构。 --- -## 4. 推荐实现方案 +## 4. 数据模型 -### 4.1 数据模型设计 +### 4.1 表设计 -#### 4.1.1 `notifications` 表 +本阶段使用两张表: + +- `notifications` +- `user_notifications` + +### 4.2 `notifications` + +职责: + +- 管理系统通知主记录 +- 管理通知内容 +- 管理发布时间、撤销、统一删除 + +建议字段: ```sql CREATE TABLE notifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - type VARCHAR(32) NOT NULL, -- 'system'|'activity'|'version'|'social' - priority SMALLINT DEFAULT 0, -- 0=normal, 1=high + type VARCHAR(32) NOT NULL DEFAULT 'system', title TEXT NOT NULL, body TEXT NOT NULL, - data JSONB, -- 透传数据(deeplink 等) - action_url TEXT, - expires_at TIMESTAMPTZ, -- 过期时间,NULL=永不过期 - created_at TIMESTAMPTZ DEFAULT now(), + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + status VARCHAR(16) NOT NULL DEFAULT 'published', + published_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted_at TIMESTAMPTZ ); --- 索引 -CREATE INDEX ix_notifications_type ON notifications(type); -CREATE INDEX ix_notifications_created_at ON notifications(created_at DESC); +CREATE INDEX ix_notifications_status_created_at + ON notifications(status, created_at DESC); + +CREATE INDEX ix_notifications_published_at + ON notifications(published_at DESC); ``` -#### 4.1.2 `user_notifications` 表(用户通知记录 + 状态) +字段语义: + +- `status='draft'`:草稿,未对用户生效 +- `status='published'`:已发布 +- `status='revoked'`:已撤销,不再对用户展示 +- `deleted_at`:平台侧软删除 + +### 4.3 `user_notifications` + +职责: + +- 表示某个用户收到某条通知 +- 记录用户已读状态 +- 支撑未读数统计 + +建议字段: ```sql CREATE TABLE user_notifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE, - dedupe_key TEXT NOT NULL, -- 幂等键(建议: campaign_id:user_id 或业务事件ID) - - -- 状态字段 - is_read BOOLEAN DEFAULT FALSE, -- 已读(点击进入详情) - is_seen BOOLEAN DEFAULT FALSE, -- 已看(列表中曝光) - is_opened BOOLEAN DEFAULT FALSE, -- 从推送打开(客户端回执) + is_read BOOLEAN NOT NULL DEFAULT FALSE, read_at TIMESTAMPTZ, - seen_at TIMESTAMPTZ, - opened_at TIMESTAMPTZ, - - -- 推送状态(服务端可验证状态,不直接使用 delivered 语义) - push_state SMALLINT DEFAULT 0, -- 0=queued, 1=sent, 2=provider_ack, 3=failed - push_provider VARCHAR(16), -- apns|fcm - push_error_code TEXT, - push_sent_at TIMESTAMPTZ, - push_provider_ack_at TIMESTAMPTZ, - - created_at TIMESTAMPTZ DEFAULT now(), - updated_at TIMESTAMPTZ DEFAULT now(), - deleted_at TIMESTAMPTZ, - - UNIQUE(user_id, dedupe_key) + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); --- 索引 -CREATE INDEX ix_user_notifications_user_id ON user_notifications(user_id); -CREATE INDEX ix_user_notifications_user_unread ON user_notifications(user_id, is_read) WHERE deleted_at IS NULL; -CREATE INDEX ix_user_notifications_created_at ON user_notifications(created_at DESC); -CREATE INDEX ix_user_notifications_user_seen ON user_notifications(user_id, is_seen) WHERE deleted_at IS NULL; -CREATE INDEX ix_user_notifications_push_state ON user_notifications(push_state) WHERE deleted_at IS NULL; +CREATE INDEX ix_user_notifications_user_created_at + ON user_notifications(user_id, created_at DESC); + +CREATE INDEX ix_user_notifications_user_unread + ON user_notifications(user_id, is_read); + +CREATE UNIQUE INDEX uq_user_notifications_user_notification + ON user_notifications(user_id, notification_id); ``` -#### 4.1.3 `user_push_devices` 表 +### 4.4 ORM 约定 -```sql -CREATE TABLE user_push_devices ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - device_token TEXT NOT NULL, -- APNs/FCM token - device_type VARCHAR(16) NOT NULL, -- 'ios'|'android' - push_provider VARCHAR(32) NOT NULL, -- 'apns'|'fcm'|'huawei'|'xiaomi' - app_version TEXT, - locale VARCHAR(16), +新模型必须继承现有 ORM 基类约定: - is_active BOOLEAN DEFAULT TRUE, - last_used_at TIMESTAMPTZ DEFAULT now(), - created_at TIMESTAMPTZ DEFAULT now(), - updated_at TIMESTAMPTZ DEFAULT now(), - deleted_at TIMESTAMPTZ, +- `Notification(TimestampMixin, SoftDeleteMixin, Base)` +- `UserNotification(TimestampMixin, Base)` - UNIQUE(user_id, device_token, push_provider) -); +说明: --- 索引 -CREATE INDEX ix_user_push_devices_user_id ON user_push_devices(user_id); -CREATE INDEX ix_user_push_devices_token ON user_push_devices(device_token); -CREATE INDEX ix_user_push_devices_active ON user_push_devices(user_id, is_active) WHERE deleted_at IS NULL; +- `notifications` 需要平台侧软删除能力 +- `user_notifications` 当前不需要 `deleted_at` + +--- + +## 5. JSONB 与 Schema 约束 + +凡是数据库字段使用 `JSONB`,必须先定义明确的 Pydantic schema,再允许落库。 + +强约束: + +- 禁止无约束 JSON 直接入库 +- 禁止先放 `dict[str, object]` 再补协议 +- schema 变更必须先更新协议文档,再更新后端与前端解析 + +当前通知方案中,这条约束直接作用于 `notifications.payload`。 + +### 5.1 `payload` 职责 + +`payload` 只负责: + +- 用户点击通知后,客户端应该做什么 + +`payload` 不负责: + +- 展示文案 +- 用户状态 +- 服务端内部状态 +- 统计、权限、跟踪信息 + +### 5.2 `payload` 字段设计 + +字段: + +- `action` +- `route` +- `entity_id` +- `tab` +- `url` + +字段职责: + +- `action` + - 点击动作类型 + - 只允许:`none`、`open_route`、`open_url` +- `route` + - `action='open_route'` 时使用 + - App 内目标路由 +- `entity_id` + - 可选业务对象 ID +- `tab` + - 可选子页面定位参数 +- `url` + - `action='open_url'` 时使用 + - 外链地址 + +使用规则: + +- `action='none'` + - `route/entity_id/tab/url` 都为空 +- `action='open_route'` + - `route` 必填 + - `entity_id/tab` 可选 + - `url` 为空 +- `action='open_url'` + - `url` 必填 + - `route/entity_id/tab` 为空 + +不加入以下字段: + +- `params` +- `metadata` +- `tracking` +- `buttons` +- `image` +- `badge_delta` + +### 5.3 Pydantic Schema + +```python +class NotificationPayloadNone(BaseModel): + model_config = ConfigDict(extra="forbid") + + action: Literal["none"] + + +class NotificationPayloadRoute(BaseModel): + model_config = ConfigDict(extra="forbid") + + action: Literal["open_route"] + route: str = Field(max_length=200) + entity_id: str | None = Field(default=None, max_length=64) + tab: str | None = Field(default=None, max_length=32) + + +class NotificationPayloadUrl(BaseModel): + model_config = ConfigDict(extra="forbid") + + action: Literal["open_url"] + url: str = Field(max_length=500) + + +NotificationPayload = ( + NotificationPayloadNone + | NotificationPayloadRoute + | NotificationPayloadUrl +) ``` -#### 4.1.4 `notification_push_attempts` 表(推送尝试日志) +--- -```sql -CREATE TABLE notification_push_attempts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_notification_id UUID NOT NULL REFERENCES user_notifications(id) ON DELETE CASCADE, - provider VARCHAR(16) NOT NULL, -- apns|fcm - attempt_no SMALLINT NOT NULL, - request_id TEXT, - result SMALLINT NOT NULL, -- 0=sent, 1=provider_ack, 2=failed, 3=timeout - error_code TEXT, - error_detail TEXT, - created_at TIMESTAMPTZ DEFAULT now() -); +## 6. 生命周期语义 -CREATE INDEX ix_notification_push_attempts_un_id ON notification_push_attempts(user_notification_id, created_at DESC); -``` +### 6.1 撤销 -#### 4.1.5 状态字段说明 +- 更新 `notifications.status = 'revoked'` +- 写入 `revoked_at` +- 查询列表和未读数时默认不返回已撤销通知 +- 前台收到撤销事件后移除或失效本地项 -| 字段 | 含义 | 触发条件 | -|------|------|----------| -| `is_seen` | 用户在列表中看到该通知 | 列表滚动曝光(停留 > 1秒)或拉取 | -| `is_read` | 用户点击进入详情 | 点击通知项 | -| `is_opened` | 用户从系统通知打开 App | 客户端上报 open 回执 | -| `push_state=sent` | 推送请求已发出 | 服务端调用 APNs/FCM 成功返回 | -| `push_state=provider_ack` | 平台确认接收 | APNs/FCM Provider 级确认 | -| `push_state=failed` | 推送失败 | 服务端重试耗尽或不可恢复错误 | +### 6.2 统一删除 -> 注意:`provider_ack` 不等价于“用户已看到通知”。用户可见性必须以 `is_opened`/`is_seen`/`is_read` 为准。 +- 更新 `notifications.deleted_at` +- 查询列表和未读数时默认过滤 `deleted_at IS NULL` +- 如未来需要物理清理,单独实现后台清理任务 -### 4.2 后端 API 设计 +--- -#### 4.2.1 通知路由 (`backend/src/v1/notifications/`) +## 7. API 方案 + +正式实现前,先补协议文档: + +- `docs/protocols/notification/notification-inbox-protocol.md` + +本阶段接口: | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/v1/notifications` | 获取当前用户通知列表 | -| GET | `/api/v1/notifications/unread-count` | 获取未读数 | -| PATCH | `/api/v1/notifications/{id}/seen` | 标记为已看 | -| PATCH | `/api/v1/notifications/{id}/read` | 标记为已读 | -| PATCH | `/api/v1/notifications/mark-all-read` | 全部已读 | -| DELETE | `/api/v1/notifications/{id}` | 删除通知 | +| GET | `/api/v1/notifications/unread-count` | 获取当前用户未读数 | +| PATCH | `/api/v1/notifications/{id}/read` | 标记单条通知已读 | +| PATCH | `/api/v1/notifications/mark-all-read` | 全部标记已读 | -#### 4.2.2 设备路由 (`backend/src/v1/push/`) +约束: -| 方法 | 路径 | 说明 | -|------|------|------| -| POST | `/api/v1/push/devices` | 注册/更新设备 token | -| DELETE | `/api/v1/push/devices/{id}` | 删除设备 | -| GET | `/api/v1/push/devices` | 获取用户设备列表 | +- 所有接口只作用于当前登录用户 +- `user_id` 必须来自 JWT `sub` +- `read` 和 `mark-all-read` 必须幂等 +- 列表查询必须联表过滤 `notifications.status` 和 `notifications.deleted_at` +- 错误返回遵循 RFC 7807 + `code` -### 4.3 Flutter 端设计 +建议列表项响应字段: -#### 4.3.1 目录结构(遵循 AGENTS.md) +- `id` +- `notification_id` +- `type` +- `title` +- `body` +- `payload` +- `is_read` +- `read_at` +- `created_at` -``` -apps/lib/ -├── core/ -│ └── notification/ # NEW -│ ├── models/ -│ │ ├── notification.dart -│ │ └── push_device.dart -│ ├── services/ -│ │ ├── notification_service.dart -│ │ └── push_service.dart -│ └── repositories/ -│ └── notification_repository.dart -├── features/ -│ └── notifications/ # NEW -│ ├── data/ -│ │ ├── apis/notification_api.dart -│ │ └── repositories/notification_repository_impl.dart -│ ├── presentation/ -│ │ ├── screens/notification_center_screen.dart -│ │ ├── widgets/notification_item.dart -│ │ └── bloc/notification_bloc.dart -├── shared/ -│ └── widgets/ -│ └── notification/ # NEW -│ ├── notification_badge.dart -│ └── notification_toast.dart -``` +本阶段不提供: -#### 4.3.2 通知中心流程 +- `PATCH /seen` +- `POST /opened` +- `DELETE /notifications/{id}` +- `/push/devices/*` -``` -┌──────────────────────────────────────────────────────────────┐ -│ App 打开 / HomeScreen │ -│ 1. 初始化时连接 Supabase Realtime 私有频道 │ -│ 2. 监听 Broadcast 通知事件 │ -│ 3. 实时更新本地列表和未读数 Badge │ -└──────────────────────────────────────────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────────┐ -│ NotificationCenterScreen │ -│ 1. 首次进入 → 调用 GET /api/v1/notifications │ -│ 2. 滚动曝光 → 调用 PATCH /api/v1/notifications/{id}/seen │ -│ 3. 点击通知 → 调用 PATCH /api/v1/notifications/{id}/read │ -│ 4. 下拉刷新 → 重新拉取列表 │ -└──────────────────────────────────────────────────────────────┘ -``` +--- -### 4.4 实时同步方案 +## 8. 后端方案 -**Supabase Realtime 职责**: -- 当用户在另一设备读取通知时,当前设备实时更新 `is_read` / `is_seen` / `is_opened` 状态 -- 仅做前台状态分发,不承担离线触达 +### 8.1 新增内容 -**推荐通道:Broadcast(私有频道)** +- Alembic 迁移:新增 `notifications`、`user_notifications` +- `backend/src/models/notification.py` +- `backend/src/models/user_notification.py` +- `backend/src/v1/notifications/` + - `schemas.py` + - `repository.py` + - `service.py` + - `router.py` +- 更新 `backend/src/models/__init__.py` + +### 8.2 设计约束 + +- 遵循 `schema -> repository -> service` 分层 +- 越权访问必须返回标准问题详情错误 +- 默认按 `created_at DESC` 返回列表 +- 已读更新只允许作用于当前用户自己的通知 +- 任何 `JSONB` 字段都必须先有 Pydantic schema 和协议定义 + +### 8.3 通知写入方式 + +本阶段不做完整运营后台和复杂 fanout。 + +最小写入入口: + +1. 业务服务内部创建 `notifications` 主记录 +2. 为目标用户写入 `user_notifications` +3. 如需调试,可使用开发环境脚本或种子数据 + +本阶段不引入: + +- Redis outbox +- Taskiq worker +- 推送 provider SDK +- 重试链路 + +--- + +## 9. Realtime 方案 + +Realtime 只负责前台同步,不负责离线触达。 + +目标: + +- App 前台打开时,新通知自动出现 +- 首页 badge 自动更新 +- 撤销通知自动从前台生效 + +事件范围: + +- `notification_created` +- `notification_read_updated` +- `notification_revoked` + +原则: + +- Realtime 是 HTTP 的增量补充,不替代首次全量拉取 +- 客户端首次进入页面仍先拉 HTTP 列表和未读数 +- 收到事件后只做本地增量更新 +- 只同步当前用户自己的通知事件 + +--- + +## 10. Flutter 方案 + +### 10.1 入口 + +复用 `HomeScreen` 现有通知按钮: + +- 位置不变 +- 点击后从 `featurePending` 改为进入通知中心 +- 右上角显示未读 badge +- 未读数为 `0` 时不显示 badge 或只显示红点 +- 数量较大时显示 `99+` + +### 10.2 状态承载 + +第一阶段优先沿用当前代码模式: + +- 在 `apps/lib/app/app.dart` 中创建通知 API 和状态 +- 在 `app/app.dart` 中持有通知列表与未读数 +- 通过构造参数和回调传给 `HomeScreen` 与通知页面 + +不在本计划中预设新的 Bloc/Cubit/Provider 架构。 + +### 10.3 模块结构 + +通知 feature 复用现有 `data/apis`、`data/models`、`data/repositories` 组织方式。 + +建议目录: ```text -DB update(user_notifications) - -> trigger 调用 realtime.broadcast_changes(...) - -> channel: user:{user_id}:notifications - -> Flutter 收到事件并更新本地缓存 +apps/lib/features/notifications/ +├── data/ +│ ├── apis/notification_api.dart +│ ├── models/notification_item.dart +│ ├── models/notification_payload.dart +│ └── repositories/notification_repository.dart +└── presentation/ + ├── screens/notification_center_screen.dart + └── widgets/notification_list_item.dart ``` -**为什么不用 postgres_changes 作为主通道**: -- 在订阅规模扩大时,`postgres_changes` 会放大 RLS 评估开销 -- Broadcast 在通知场景下更可控,可按用户私有频道精准下发 +### 10.4 数据对接 + +前端必须先做强类型解析,再交给页面层使用。 + +复用现有模式: + +- API 层拿原始 JSON +- 在 API/模型层解析为强类型对象 +- 页面层只消费模型 + +建议前端模型: ```dart -final channel = supabase.channel( - 'user:${userId}:notifications', - opts: const RealtimeChannelConfig(private: true), -); +class NotificationItem { + const NotificationItem({ + required this.id, + required this.notificationId, + required this.type, + required this.title, + required this.body, + required this.payload, + required this.isRead, + required this.createdAt, + this.readAt, + }); -channel.onBroadcast( - event: 'notification_changed', - callback: (payload) { - // 更新本地状态和 Badge - }, -).subscribe(); + final String id; + final String notificationId; + final String type; + final String title; + final String body; + final NotificationPayload payload; + final bool isRead; + final DateTime createdAt; + final DateTime? readAt; +} ``` -### 4.5 推送发送流程(Outbox + Worker) +`payload` 也必须单独解析,不能在 widget 中直接读 map。 -``` -┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ -│ 业务触发 │────▶│ DB 事务写入 │────▶│ push_outbox │ -│ (后台任务) │ │ notifications + │ │ pending 事件 │ -└─────────────┘ │ user_notifications│ └────────┬────────┘ - └──────────────────┘ │ - ▼ - ┌─────────────────┐ - │ Fanout Worker │ - │ 读取 outbox │ - └────────┬────────┘ - │ - ┌─────────────────────────────────┼─────────────────────────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ - │ Broadcast │ │ APNs / FCM │ │ 重试 / DLQ │ - │ 前台增量同步 │ │ 离线触达 │ │ 指数退避 │ - └──────────────┘ └──────────────┘ └──────────────┘ +### 10.5 `payload` 的 Dart 模型 + +```dart +sealed class NotificationPayload { + const NotificationPayload(); +} + +final class NotificationPayloadNone extends NotificationPayload { + const NotificationPayloadNone(); +} + +final class NotificationPayloadRoute extends NotificationPayload { + const NotificationPayloadRoute({ + required this.route, + this.entityId, + this.tab, + }); + + final String route; + final String? entityId; + final String? tab; +} + +final class NotificationPayloadUrl extends NotificationPayload { + const NotificationPayloadUrl({required this.url}); + + final String url; +} ``` -### 4.6 幂等、重试与失败处理 +解析原则: -#### 4.6.1 幂等策略 +- 后端响应 JSON 在 API 层一次性解析成强类型模型 +- 解析失败必须抛错并记录 +- 未知 `action` 视为协议错误 -- 每次业务发通知必须携带 `dedupe_key` -- `UNIQUE(user_id, dedupe_key)` 保证重复投递不会生成多条记录 -- `PATCH /read`、`PATCH /seen` 必须幂等,重复调用返回 200 且不重复更新统计 +### 10.6 通知中心页面 -#### 4.6.2 重试策略 +页面形态:标准列表式 inbox。 -- 可重试错误:超时、5xx、429 -- 不可重试错误:token invalid、payload invalid、认证失败 -- 退避策略:`30s -> 2m -> 10m -> 1h`,最多 4 次 -- 超过上限进入 DLQ,由运维任务定时重放或人工处理 +页面包含: -#### 4.6.3 失败模式清单 +- 标题栏:`通知` +- 右上角操作:`全部已读` +- 主体:通知列表 +- 空状态 +- 下拉刷新 -| 场景 | 风险 | 方案 | -|------|------|------| -| 推送平台短时不可用 | 批量失败 | Outbox + 指数退避 + DLQ | -| 客户端重复上报 read | 状态抖动/统计偏差 | 幂等更新 + 仅首次写时间戳 | -| 多设备并发 read/seen | 最终状态不一致 | 单条记录原子更新 + `updated_at` 冲突检测 | -| Realtime 断连 | UI 未及时同步 | 前台重连后触发增量拉取(按 `updated_at`) | +列表排序: -### 4.7 版本通知与活动通知 +- `created_at DESC` -#### 4.7.1 版本通知 +列表项最小展示字段: -- 触发条件:用户登录时检测到 App 版本低于最新版本 -- 写入方式:后台任务扫描所有用户,批量写入 `user_notifications` -- `notification.type = 'version'` +- `title` +- `body` +- `created_at` +- `is_read` -#### 4.7.2 活动通知 +交互: -- 触发条件:运营后台或定时任务触发 -- 目标用户:可按标签/行为筛选 -- `notification.type = 'activity'` +- 点击通知项 + - 若未读,先标记已读 + - 再执行 `payload.action` 对应跳转 +- 已撤销通知 + - Realtime 收到撤销事件后移除 -### 4.8 权限与安全策略 +### 10.7 前端状态流转 -#### RLS 设计 +最小状态: -```sql --- user_notifications -ALTER TABLE user_notifications ENABLE ROW LEVEL SECURITY; +- 通知列表 +- 未读数 -CREATE POLICY auth_select ON user_notifications FOR SELECT - USING (auth.uid() = user_id AND deleted_at IS NULL); +最小流转: -CREATE POLICY auth_update ON user_notifications FOR UPDATE - USING (auth.uid() = user_id) - WITH CHECK (auth.uid() = user_id); +1. App 进入首页或相关模块初始化时拉未读数 +2. 进入通知中心时拉列表并同步未读数 +3. 点击单条通知时更新已读并减少未读数,再执行跳转 +4. 点击“全部已读”时将列表设为已读并将 badge 归零 +5. 收到 Realtime 事件时: + - 新增:插入列表顶部并递增未读数 + - 已读:更新对应项并调整未读数 + - 撤销:移除对应项并重新校正未读数 --- user_push_devices -ALTER TABLE user_push_devices ENABLE ROW LEVEL SECURITY; +### 10.8 前端 Realtime 处理 -CREATE POLICY auth_all ON user_push_devices FOR ALL - USING (auth.uid() = user_id AND deleted_at IS NULL) - WITH CHECK (auth.uid() = user_id); -``` +通知 Realtime 沿用当前仓库已有的“事件流 -> 解析 -> 强类型对象 -> 状态更新”思路。 -#### 信任边界 +建议事件模型: -- 客户端不能创建通知主记录,通知内容仅由服务端写入 -- 客户端只能操作自己的 `seen/read/open` 回执和设备 token -- `owner_id/user_id` 必须来自 JWT `sub`,禁止客户端传入 -- 管理后台发通知接口必须走服务角色鉴权,不暴露给普通用户 token +- `NotificationCreatedEvent` +- `NotificationReadUpdatedEvent` +- `NotificationRevokedEvent` -### 4.9 可观测性与运行指标 +处理原则: -| 指标 | 目标/SLO | 备注 | -|------|----------|------| -| 通知写入成功率 | >= 99.9% | 5 分钟窗口 | -| 推送发送成功率(sent) | >= 99.5% | 按 provider 分维度 | -| 推送 provider_ack 延迟 P95 | < 3s | 仅平台确认链路 | -| 前台实时同步延迟 P95 | < 1s | Broadcast 收包到 UI 更新 | -| read API P95 | < 150ms | 排除网络抖动 | +- 事件到达后先校验结构,再更新本地状态 +- 本地不存在对应通知时,不崩溃;必要时触发轻量刷新 +- Realtime 不替代首次 HTTP 全量拉取 -日志要求:每次通知发送链路打通 `trace_id`(创建 -> outbox -> provider -> 回执)。 +### 10.9 页面跳转执行规则 -### 4.10 NOT in scope(本阶段不做) +通知点击逻辑集中处理,不散落在列表 widget 中。 -- 厂商通道(小米/华为)深度集成 -- 通知模板多语言管理后台 -- 通知 A/B 实验平台 -- 全量历史归档/冷热分层 +建议统一入口: + +- `handleNotificationTap(NotificationItem item)` + +执行顺序: + +1. 判断是否未读 +2. 若未读,调用 repository 标记已读 +3. 根据 `payload.action` 执行行为 +4. 跳转失败时记录错误,但不回滚已读状态 + +行为映射: + +- `none` + - 不跳转,或停留通知中心 +- `open_route` + - 使用现有 `Navigator.of(context).push(MaterialPageRoute(...))` 组织 App 内导航 +- `open_url` + - 使用统一外链打开能力 + +### 10.10 本阶段不新增的依赖 + +- `firebase_messaging` +- `flutter_local_notifications` + +是否引入 `supabase_flutter` 或其他 Realtime 客户端,取决于最终接入方案;在协议确认前不写死。 + +### 10.11 与现有设置项关系 + +`profiles.settings.notification.allow_notifications` 和 `allow_vibration` 保持现状: + +- 不删除 +- 不扩字段 +- 不承担站内通知已读状态 --- -## 5. 分阶段落地计划 +## 11. 实施清单 -### 第一阶段:最小可用通知中心(MVP 收敛) - -**目标**:在 App 内显示通知列表,支持标记已读 - -**改动范围**: -- Backend: `notifications` 表 + API -- Flutter: 通知中心页面 + Repository - -**主要任务**: -1. 创建 `notifications` 和 `user_notifications` 表 -2. 实现 `GET /api/v1/notifications` API -3. 实现 `PATCH /api/v1/notifications/{id}/read` API -4. Flutter 通知中心页面(列表 + 标记已读) -5. 未读数 Badge(在 HomeScreen 通知图标上显示红点) -6. 完成协议文档:`docs/protocols/notification/notification-protocol.md` -7. 完成基础测试(见第 7 节) - -**依赖项**:无 - -**风险点**: -- 无推送,通知需要后台手动写入 DB -- 不支持实时同步,多设备体验差 - -**验收标准**: -- [ ] 能看到通知列表 -- [ ] 点击通知能标记为已读 -- [ ] 未读通知有 Badge 提示 -- [ ] 已读/未读状态在下拉刷新后正确 -- [ ] 越权访问被拒绝(用户 A 无法读写用户 B 通知) -- [ ] read 接口重复调用幂等 +1. 编写协议文档 `docs/protocols/notification/notification-inbox-protocol.md` +2. 新增 `notifications`、`user_notifications` 表迁移 +3. 实现后端通知模型、schema、repository、service、router +4. 实现通知列表、未读数、单条已读、全部已读接口 +5. 定义并实现通知 Realtime 事件协议 +6. 新增 Flutter 通知 feature、通知中心页面和列表项组件 +7. 在 `app/app.dart` 中接入通知 API、状态和 Realtime 订阅 +8. 将 Home 页通知按钮接入真实页面并展示 badge +9. 完成最小测试 --- -### 第二阶段:接入系统推送 +## 12. 验收标准 -**目标**:支持离线推送通知 - -**改动范围**: -- Backend: `user_push_devices` 表 + Push Service -- Flutter: 推送接收 + 设备注册 - -**主要任务**: -1. 创建 `user_push_devices` 表 -2. 实现 `POST /api/v1/push/devices` API(注册 token) -3. 集成 firebase_messaging(Android)/ apns(iOS) -4. 实现 Push Sending Service(APNs/FCM SDK)+ Outbox Worker -5. Flutter 端:获取 FCM/APNs token 并注册 -6. 服务端发送后更新 `push_state`,客户端仅上报 open/read/seen 回执 -7. 落地重试与 DLQ - -**依赖项**: -- 第一阶段完成 -- Firebase 项目配置(Android) -- APNs 证书/Key(iOS) - -**风险点**: -- APNs/FCM 配置复杂 -- 推送送达率不稳定(特别是国内 Android) - -**验收标准**: -- [ ] 离线设备能收到推送 -- [ ] 点击推送能打开对应通知详情 -- [ ] 推送状态(sent/provider_ack/failed)正确回写 -- [ ] provider 短时故障时消息可自动重试并可追踪 +- [ ] 能为指定用户写入一条站内通知 +- [ ] 用户能看到自己的通知列表 +- [ ] 用户点击通知后可标记为已读 +- [ ] “全部已读”后未读数归零 +- [ ] 用户 A 不能读取或修改用户 B 的通知 +- [ ] 已读接口重复调用不会报错,也不会产生脏状态 +- [ ] App 前台打开时,服务端新写入的通知可自动出现在列表中 +- [ ] 首页 badge 会随新增通知和已读操作自动更新 +- [ ] 撤销或统一删除主通知后,用户侧列表不再展示对应通知 --- -### 第三阶段:实时同步与统计 +## 13. 测试要求 -**目标**:多设备实时同步 + 完整状态追踪 +后端至少覆盖: -**改动范围**: -- Backend: Supabase Realtime + 状态统计 -- Flutter: Realtime 订阅 + 曝光追踪 +- 列表只返回当前用户数据 +- 未读数统计正确 +- 单条已读幂等 +- 全部已读幂等 +- 越权访问被拒绝 +- 已撤销或已删除主通知不会出现在列表和未读统计中 -**主要任务**: -1. Supabase Realtime Broadcast 私有频道接入 -2. 实现 `seen` 状态(曝光追踪) -3. 添加统计接口(送达率、点击率) -4. Flutter 端:列表滚动时标记 `seen` -5. 版本通知、活动通知的后台写入逻辑 -6. 断线重连后的增量拉取机制(按 `updated_at`) +Flutter 至少覆盖: -**依赖项**: -- 第二阶段完成 -- Supabase Realtime 已启用 +- 通知模型解析 +- 未读数展示逻辑 +- 列表点击后状态刷新 +- Realtime 事件驱动下的列表或 badge 更新逻辑 -**风险点**: -- Realtime 连接数限制(根据 plan) -- 曝光追踪可能影响性能 +本阶段不要求测试: -**验收标准**: -- [ ] 一设备读取通知,另一设备实时更新 -- [ ] 列表曝光能正确标记 `seen` -- [ ] 能查看送达/点击统计 -- [ ] Realtime 断连恢复后不丢状态更新 +- 推送送达率 +- 设备注册 +- 系统级离线推送 --- -## 6. 建议改动清单 +## 14. 后续扩展条件 -### 6.1 新增/修改的表 +只有在真实需求出现时,才继续扩展: -| 表名 | 操作 | 说明 | -|------|------|------| -| `notifications` | 新增 | 通知模板表 | -| `user_notifications` | 新增 | 用户通知记录+状态 | -| `user_push_devices` | 新增 | 设备 token 存储 | -| `notification_push_attempts` | 新增 | 推送尝试日志 | -| `profiles` | 修改 | 暂不改,优先在线计算 unread_count | +### 14.1 扩到更多表 -### 6.2 新增后端模块 +出现以下需求之一时,再评估扩展到三张或四张表: -| 路径 | 说明 | -|------|------| -| `backend/src/v1/notifications/` | 通知路由+服务+仓库 | -| `backend/src/v1/push/` | 推送设备路由+服务 | -| `backend/src/services/push/` | APNs/FCM 发送服务 | -| `backend/src/services/push/outbox_worker.py` | 推送 outbox 消费与重试 | -| `backend/src/models/notification.py` | 通知 ORM 模型 | -| `backend/src/models/notification_push_attempt.py` | 推送尝试日志模型 | -| `backend/src/schemas/notification.py` | Pydantic schemas | +- 同一通知内容批量投递给大量用户 +- 需要模板复用 +- 需要设备级投递状态追踪 +- 需要运营后台批量发送 -### 6.3 新增 Flutter 模块 +届时再评估是否新增: -| 路径 | 说明 | -|------|------| -| `apps/lib/core/notification/` | 通知核心逻辑 | -| `apps/lib/features/notifications/` | 通知功能模块 | -| `apps/lib/shared/widgets/notification/` | 通知 UI 组件 | +- `user_push_devices` +- `notification_push_attempts` -### 6.4 新增接口 +### 14.2 接入系统级离线推送 -| 方法 | 路径 | 说明 | -|------|------|------| -| GET | `/api/v1/notifications` | 通知列表 | -| GET | `/api/v1/notifications/unread-count` | 未读数 | -| PATCH | `/api/v1/notifications/{id}/seen` | 标记已看 | -| PATCH | `/api/v1/notifications/{id}/read` | 标记已读 | -| PATCH | `/api/v1/notifications/mark-all-read` | 全部已读 | -| DELETE | `/api/v1/notifications/{id}` | 删除通知 | -| POST | `/api/v1/notifications/{id}/opened` | 上报从推送打开 | -| POST | `/api/v1/push/devices` | 注册设备 | -| DELETE | `/api/v1/push/devices/{id}` | 删除设备 | +只有在确认以下需求时才接入: -### 6.5 新增配置项 +- App 在后台或离线时也要触达用户 +- iOS / Android 需要真正弹出系统通知 -```env -# .env -ERYAO_PUSH__APNS_KEY_ID=xxx -ERYAO_PUSH__APNS_TEAM_ID=xxx -ERYAO_PUSH__APNS_KEY_PATH=/path/to/key.p8 -ERYAO_PUSH__FCM_SERVER_KEY=xxx -ERYAO_PUSH__ENABLED=true -``` +届时再补: -### 6.6 Flutter 依赖(pubspec.yaml) - -```yaml -dependencies: - firebase_messaging: ^15.0.0 # FCM - flutter_local_notifications: ^18.0.0 # 本地通知 - supabase_flutter: ^2.5.0 # Supabase 客户端(含 Realtime) -``` - -### 6.7 协议文档(先于实现) - -```text -docs/protocols/notification/ -├── notification-protocol.md # 数据模型、字段语义、兼容策略 -├── notification-api-protocol.md # API 请求/响应、错误码 -└── notification-realtime-protocol.md # Broadcast payload 与版本约束 -``` - ---- - -## 7. 测试策略(必须随阶段落地) - -### 7.1 测试框架 - -- 后端:`pytest` + `pytest-asyncio`(见 `pyproject.toml`) -- Flutter:单测 + Widget 测试 + 关键链路集成测试 - -### 7.2 覆盖图(关键分支) - -```text -Create Notification - -> validate payload - -> invalid type [unit] - -> expires_at in past [unit] - -> fanout target users - -> empty target set [unit] - -> partial user write failed [integration] - -> write user_notifications (dedupe) - -> first write success [unit] - -> duplicate dedupe_key [unit] - -> enqueue outbox - -> enqueue success [integration] - -> enqueue failed rollback [integration] - -Push Worker - -> send provider - -> sent/provider_ack [integration] - -> timeout retry [integration] - -> permanent failure to DLQ [integration] - -User Callback - -> PATCH seen/read/opened - -> own record success [api] - -> cross-user forbidden [api/security] - -> repeat call idempotent [api] - -Realtime - -> broadcast receive update [integration] - -> reconnect + incremental pull [e2e] -``` - -### 7.3 最低覆盖要求 - -- 新增后端核心分支(通知创建、状态更新、重试)语句覆盖率 >= 90% -- 安全相关路径(越权、伪造 user_id、非法 token)必须 100% 有测试 -- 每个阶段必须包含至少 1 条回归测试,防止旧行为被破坏 - -### 7.4 回归测试强制项 - -1. 同一通知重复发送不应生成重复 `user_notifications` -2. `mark-all-read` 与单条 `read` 并发时,最终状态一致 -3. Realtime 中断后恢复,未读数与服务端一致 -4. 无效/过期 token 不应导致消息丢失(进入失败态并可重试) - ---- - -## 8. 最终推荐 - -### 8.1 推荐总体方案 - -**采用 Supabase Realtime Broadcast(前台)+ APNs/FCM(后台/离线)的混合方案** - -### 8.2 推荐原因 - -1. **用户体验最优**:前台实时同步,多设备状态一致 -2. **离线可达**:通过 APNs/FCM 触达离线用户 -3. **扩展性更稳**:Broadcast 规避高并发下 `postgres_changes` 的权限扫描放大 -4. **状态语义更准确**:`provider_ack` 与用户可见状态分离,统计更可信 -5. **项目适配**:Supabase 已在用,Realtime 只是扩展使用 - -### 8.3 不确定点 - -| 问题 | 推断依据 | -|------|----------| -| 是否已有 Firebase 项目 | 未在代码库中找到 `google-services.json` 或 Firebase 配置 | -| 是否已有 APNs 证书 | 未在代码库中找到证书文件 | -| 国内 Android 推送需求 | 国内 Android ROM 需要厂商通道(如小米、华为),建议预留 | - -### 8.4 实施优先级排序 - -| 优先级 | 阶段 | 说明 | -|--------|------|------| -| **P0** | 第一阶段 | MVP:通知列表 + 标记已读 | -| **P1** | 第一阶段 | 未读数 Badge | -| **P2** | 第二阶段 | 推送接入(Android FCM 先,iOS APNs 后) | -| **P3** | 第三阶段 | Realtime 同步 | -| **P4** | 第三阶段 | 曝光追踪 + 统计 | - -### 8.5 关键约束提醒 - -1. **遵循 AGENTS.md**:通知代码放在 `core/notification/` 和 `shared/widgets/notification/` -2. **Error Swallowing**:所有异常必须传播,禁止静默捕获 -3. **Protocol 先行**:新建通知相关协议文档在 `docs/protocols/notification/` -4. **渐进演进**:不废弃现有 `NotificationSettings`,而是扩展它 - ---- - -## 附录:推断依据 - -| 信息点 | 推断依据 | -|--------|----------| -| 无通知表 | 所有 migration 文件中均未发现 `notifications` 相关表 | -| 无推送集成 | `pubspec.yaml` 无 firebase_messaging / apns 相关依赖 | -| 无 Realtime 通知 | Supabase Service 仅用于 Storage,未配置 Realtime | -| AGENTS.md 约束 | `apps/AGENTS.md` 明确提到 "Reminder/Notification Rewrite Boundary" | -| 推送服务配置 | `settings.py` 无任何 APNs/FCM 相关配置 | +- 设备 token 注册 +- APNs / FCM 配置 +- 推送发送服务 +- 失败重试和审计链路 diff --git a/docs/plans/static-notification-sync-plan.md b/docs/plans/static-notification-sync-plan.md new file mode 100644 index 0000000..c799513 --- /dev/null +++ b/docs/plans/static-notification-sync-plan.md @@ -0,0 +1,484 @@ +# 静态通知配置同步计划 + +> 更新时间:2026-04-10 +> 状态:最终执行版 + +## 1. 目标 + +为通知系统增加一条独立的“静态配置 -> 数据库同步”链路,使服务端可以从仓库内的通知配置文件读取通知定义,并将其注册、更新或撤销到数据库。 + +本计划解决的问题: + +- 通过静态文件维护系统通知内容 +- 手动触发后端读取并同步通知到数据库 +- 支持已有通知的修改 +- 支持已有通知的撤销 +- 保持用户侧已读状态不因通知内容更新而丢失 + +本计划不替代主通知系统计划,而是在其基础上增加“静态通知同步”能力。 + +关联文档: + +- `docs/plans/notification-system-plan.md` + +--- + +## 2. 范围 + +### 2.1 In Scope + +- 新增静态通知配置目录 +- 定义静态通知 YAML 协议 +- 定义对应的 Pydantic schema +- 实现后端扫描、校验、upsert 同步逻辑 +- 实现对主通知的修改和撤销 +- 新增手动触发同步脚本 + +### 2.2 Out of Scope + +- 系统级离线推送 +- 自动监听文件变化并实时同步 +- 通过文件删除自动删库 +- 复杂运营后台 +- 严格对齐目标用户集合并自动删除既有投递记录 + +--- + +## 3. 现有代码基线 + +当前仓库已经有可直接复用的“静态配置 -> 数据库初始化”模式: + +- 静态配置目录:`backend/src/core/config/static/database/` +- 现有 YAML: + - `llm_catalog.yaml` + - `system_agents.yaml` +- 现有加载与校验:`backend/src/core/config/initial/init_data.py` +- 现有 CLI:`backend/src/core/runtime/cli.py` +- 现有脚本:`infra/scripts/dev-migrate.sh` + +通知同步应复用这套模式的核心思路: + +- YAML 文件作为配置源 +- Pydantic schema 做强校验 +- 后端显式执行同步 +- 数据库使用 upsert 语义更新 + +但通知同步不应直接并入 `init-data/bootstrap` 默认流程,因为通知内容属于持续变更的数据,不是纯启动种子数据。 + +--- + +## 4. 目录设计 + +建议新增静态通知目录: + +```text +backend/src/core/config/static/notification/ +└── notifications/ + ├── welcome_bonus.yaml + ├── maintenance_2026_04.yaml + └── ... +``` + +第一阶段不增加总索引文件,直接扫描 `notifications/*.yaml`。 + +原因: + +- 少一层维护成本 +- 避免“文件内容”和“索引文件”双源不一致 +- 更适合增量增加通知文件 + +--- + +## 5. 数据模型变更 + +要支持“静态文件和数据库中的同一条通知”建立稳定映射,`notifications` 表需要增加来源标识字段。 + +建议新增字段: + +- `source` +- `source_key` +- `source_version` +- `content_hash` + +建议约束: + +- `UNIQUE(source, source_key)` + +### 5.1 字段职责 + +- `source` + - 通知来源 + - 当前静态通知固定为 `static` +- `source_key` + - 静态通知唯一键 + - 例如 `welcome_bonus` + - 用于可靠 upsert +- `source_version` + - 配置版本号 + - 用于审计和变更追踪 +- `content_hash` + - 标准化内容摘要 + - 用于判断文件内容是否发生变化 + +### 5.2 推荐表结构补充 + +在 `notifications` 表基础上补充: + +```sql +ALTER TABLE notifications + ADD COLUMN source VARCHAR(32) NOT NULL DEFAULT 'manual', + ADD COLUMN source_key VARCHAR(128), + ADD COLUMN source_version INTEGER, + ADD COLUMN content_hash VARCHAR(64); + +CREATE UNIQUE INDEX uq_notifications_source_source_key + ON notifications(source, source_key) + WHERE source_key IS NOT NULL; +``` + +说明: + +- `manual` 可作为非静态创建通知的默认来源 +- 静态同步通知统一使用 `source='static'` + +--- + +## 6. 静态通知 YAML 协议 + +每个 YAML 文件描述一条主通知及其投递目标。 + +推荐结构: + +```yaml +notification: + source_key: welcome_bonus + version: 1 + type: system + status: published + published_at: 2026-04-10T08:00:00Z + + title: 新用户欢迎通知 + body: 你已获得注册奖励,可前往积分中心查看。 + + payload: + action: open_route + route: /points + entity_id: null + tab: balance + +targets: + mode: all_users +``` + +指定用户示例: + +```yaml +notification: + source_key: maintenance_2026_04 + version: 3 + type: system + status: published + title: 系统维护通知 + body: 今晚 23:00 到 23:30 进行维护。 + payload: + action: none + +targets: + mode: user_ids + user_ids: + - 11111111-1111-1111-1111-111111111111 + - 22222222-2222-2222-2222-222222222222 +``` + +--- + +## 7. Pydantic Schema 设计 + +静态通知文件必须先经过强校验,不能直接把 YAML 转 dict 入库。 + +建议新增模块: + +- `backend/src/core/config/notification/static_schema.py` + +建议 schema: + +- `StaticNotificationDefinition` +- `StaticNotificationTargets` +- `StaticNotificationFile` + +`payload` 不重新定义,直接复用现有通知协议里的 schema: + +- `NotificationPayloadNone` +- `NotificationPayloadRoute` +- `NotificationPayloadUrl` + +### 7.1 `StaticNotificationDefinition` 职责 + +- `source_key` + - 静态通知唯一键 +- `version` + - 配置版本号 +- `type` + - 通知类型,当前默认 `system` +- `status` + - `draft/published/revoked` +- `published_at` + - 发布时间 +- `title/body/payload` + - 通知内容 + +### 7.2 `StaticNotificationTargets` 职责 + +- `mode` + - `all_users` 或 `user_ids` +- `user_ids` + - 仅当 `mode='user_ids'` 时允许 + +### 7.3 校验约束 + +- `source_key` 必填且全局唯一 +- `version >= 1` +- `status` 只允许 `draft/published/revoked` +- `payload` 必须符合现有通知 payload schema +- `targets.mode='all_users'` 时不允许传 `user_ids` +- `targets.mode='user_ids'` 时 `user_ids` 必填且不能为空 + +--- + +## 8. 同步语义 + +### 8.1 新建 + +当数据库中不存在 `(source='static', source_key=...)` 时: + +1. 创建 `notifications` +2. 按目标规则写入 `user_notifications` + +### 8.2 修改 + +当数据库中已存在同一 `source_key` 时: + +1. 更新 `notifications.title/body/payload/status/published_at/source_version/content_hash` +2. 保留已有 `user_notifications` +3. 不重置 `is_read/read_at` + +这是强规则: + +- 修改主通知内容,不影响用户已读状态 + +### 8.3 撤销 + +当 YAML 中: + +- `notification.status = revoked` + +则同步时: + +1. 更新 `notifications.status='revoked'` +2. 写入 `revoked_at` +3. 不删除 `user_notifications` + +### 8.4 统一删除 + +本阶段不使用“文件消失自动删库”语义。 + +原因: + +- 文件误删风险高 +- 容易把版本控制操作误解释为业务删除 + +如果需要下线,显式通过配置状态控制: + +- `status: revoked` + +如果未来确实需要静态配置触发软删除,再单独增加明确字段,不在本阶段默认启用。 + +### 8.5 目标用户变更 + +第一阶段采用保守策略: + +- 新增目标用户时,补插入 `user_notifications` +- 被移出目标集合的用户,不自动删除既有 `user_notifications` + +原因: + +- 防止误操作删除已投递历史 +- 与“通知一旦发出就保留用户侧记录”的语义更一致 + +如果未来需要严格对齐文件目标集合,再单独增加显式 `--reconcile-targets` 行为。 + +--- + +## 9. 后端实现方案 + +### 9.1 模块位置 + +建议新增: + +```text +backend/src/core/config/notification/ +├── static_schema.py +└── static_sync.py +``` + +不建议把通知同步继续堆进 `core/config/initial/init_data.py`。 + +原因: + +- `init_data.py` 当前更适合 bootstrap seed +- 通知同步是持续执行的配置同步任务 +- 语义上应独立 + +### 9.2 组件职责 + +- `static_schema.py` + - 定义 YAML 文件的 Pydantic schema +- `static_sync.py` + - 扫描目录 + - 读取 YAML + - 校验 schema + - 计算差异 + - 执行 upsert + +现有通知模块中建议补充内部同步能力: + +- `v1/notifications/repository.py` + - 补充按 `source/source_key` 查询与 upsert +- `v1/notifications/service.py` + - 补充内部同步逻辑与事务边界 + +### 9.3 日志与错误 + +遵循现有后端规则: + +- 使用 `core.logging` +- 不使用 `print` +- YAML 校验失败要明确报错并中止 +- 数据库 upsert 失败要中止,不吞错 + +--- + +## 10. CLI 与脚本方案 + +### 10.1 后端 CLI + +在 `backend/src/core/runtime/cli.py` 中新增命令: + +- `sync-notifications` + +建议调用方式: + +```bash +PYTHONPATH=backend/src uv run python -m core.runtime.cli sync-notifications +``` + +建议参数: + +- `--path` +- `--source-key` +- `--dry-run` + +第一阶段不默认提供危险的全量清理参数。 + +### 10.2 infra 脚本 + +新增: + +```text +infra/scripts/register-notifications.sh +``` + +脚本风格复用 `infra/scripts/dev-migrate.sh`: + +- 读取 `.env` +- 通过 `uv run python -m core.runtime.cli sync-notifications` 调用后端 CLI + +建议用法: + +```bash +./infra/scripts/register-notifications.sh +./infra/scripts/register-notifications.sh --dry-run +./infra/scripts/register-notifications.sh --source-key welcome_bonus +``` + +--- + +## 11. 与现有通知系统的关系 + +这条静态同步链路只负责: + +- 把 YAML 中的通知定义注册到数据库 +- 更新通知主记录 +- 撤销通知主记录 +- 为目标用户补齐接收关系 + +它不替代现有通知 API: + +- 用户列表、未读数、已读接口仍走现有通知系统 +- Flutter 端仍然从现有通知 API 和 Realtime 获取数据 + +如果通知内容被静态同步更新,而前台需要即时看到变更,建议在 Realtime 中补充: + +- `notification_updated` + +否则前台只能在下次 HTTP 拉取时看到更新后的内容。 + +--- + +## 12. 实施清单 + +1. 为 `notifications` 表增加 `source/source_key/source_version/content_hash` +2. 增加 `(source, source_key)` 唯一约束 +3. 新增 `backend/src/core/config/static/notification/notifications/` 目录 +4. 定义静态通知 YAML 的 Pydantic schema +5. 实现 YAML 扫描、加载、校验与 upsert 同步逻辑 +6. 为通知模块补充按 `source/source_key` 查询与更新能力 +7. 在 `core.runtime.cli` 中新增 `sync-notifications` 命令 +8. 新增 `infra/scripts/register-notifications.sh` +9. 视需要补充 `notification_updated` Realtime 事件 +10. 编写最小测试和 dry-run 校验 + +--- + +## 13. 验收标准 + +- [ ] 新增一个 YAML 文件后,可成功同步出对应主通知记录 +- [ ] 相同 `source_key` 的 YAML 再次同步时,会更新主通知而不是插入重复记录 +- [ ] 修改 `title/body/payload` 后,再同步可反映到数据库 +- [ ] 用户侧已读状态在主通知内容更新后保持不变 +- [ ] 将 `status` 改为 `revoked` 后,再同步可使通知在用户列表中失效 +- [ ] `--dry-run` 可输出计划变更而不写库 +- [ ] YAML 结构不合法时同步失败,并给出明确错误 +- [ ] 脚本可按全量或按 `source_key` 手动触发同步 + +--- + +## 14. 测试要求 + +后端至少覆盖: + +- YAML schema 校验 +- 新建通知同步 +- 已有通知更新同步 +- 撤销同步 +- 相同 `source_key` 幂等 upsert +- 更新主通知时不重置 `user_notifications.is_read/read_at` +- 新增目标用户时补插入接收关系 +- 被移出目标集合时不删除既有接收关系 + +脚本至少验证: + +- 正常执行 CLI +- `--dry-run` 不写库 +- `--source-key` 只同步指定通知 + +--- + +## 15. 后续扩展条件 + +只有在真实需求出现时,再考虑: + +- 用删除文件触发软删除 +- 严格对齐目标用户集合并清理历史接收关系 +- 通过后台页面管理静态通知 +- 将静态通知同步纳入更完整的发布工作流 diff --git a/docs/protocols/common/http-error-codes.md b/docs/protocols/common/http-error-codes.md index cb16108..aa1e395 100644 --- a/docs/protocols/common/http-error-codes.md +++ b/docs/protocols/common/http-error-codes.md @@ -77,6 +77,12 @@ This document is the source of truth for backend RFC7807 `code` values consumed | `AVATAR_SIGNED_URL_FAILED` | 502 | Backend failed to generate avatar signed upload URL | Show retry toast and keep previous avatar | | `AVATAR_UPLOAD_FAILED` | 502 | Backend failed to upload avatar bytes to storage | Show retry toast and keep previous avatar | +## Notification + +| code | status | meaning | frontend handling | +|---|---:|---|---| +| `NOTIFICATION_NOT_FOUND` | 404 | Notification not found or not owned by current user | Show not-found message and refresh list | + ## Global | code | status | meaning | frontend handling | diff --git a/docs/protocols/notification/notification-inbox-protocol.md b/docs/protocols/notification/notification-inbox-protocol.md new file mode 100644 index 0000000..0d105ef --- /dev/null +++ b/docs/protocols/notification/notification-inbox-protocol.md @@ -0,0 +1,192 @@ +# Notification Inbox Protocol (Frontend <-> Backend) + +This document defines the notification inbox contract for authenticated users. + +Protocol verification status: + +- Backend route source: `backend/src/v1/notifications/router.py` +- Backend service source: `backend/src/v1/notifications/service.py` +- Backend repository source: `backend/src/v1/notifications/repository.py` +- Backend schema source: `backend/src/v1/notifications/schemas.py` + +## Compatibility strategy + +- Additive evolution only. +- Existing response fields are stable and must remain backward-compatible. +- New `action` values may be added to `payload`; unknown `action` values must be ignored by the client. + +## Routes + +### GET /api/v1/notifications + +List notifications for the current user. + +**Authorization**: Requires authenticated session. User identity from JWT `sub`. + +**Query parameters**: + +- `limit` (optional, integer, default 20, max 50): number of items per page +- `cursor` (optional, string): pagination cursor (ISO 8601 timestamp of last item's `created_at`) + +**Response (200)**: + +```json +{ + "items": [ + { + "id": "uuid", + "notificationId": "uuid", + "type": "system", + "title": "Welcome", + "body": "Welcome to the app!", + "payload": { + "action": "none" + }, + "isRead": false, + "readAt": null, + "createdAt": "2026-04-10T00:00:00Z" + } + ], + "nextCursor": "2026-04-09T12:00:00Z", + "hasMore": true +} +``` + +Field rules: + +- `items`: array of notification items, ordered by `createdAt` descending +- `nextCursor`: timestamp cursor for next page, `null` if no more items +- `hasMore`: boolean indicating if more items exist +- `type`: string, currently only `system` +- `payload`: discriminated union (see Payload section below) +- `isRead`: boolean +- `readAt`: ISO 8601 timestamp or `null` +- Results are filtered: `notifications.status = 'published'` and `notifications.deleted_at IS NULL` + +### GET /api/v1/notifications/unread-count + +Get the number of unread notifications for the current user. + +**Authorization**: Requires authenticated session. User identity from JWT `sub`. + +**Response (200)**: + +```json +{ + "count": 5 +} +``` + +Field rules: + +- `count`: integer `>= 0` +- Counts only notifications where `notifications.status = 'published'` and `notifications.deleted_at IS NULL` + +### PATCH /api/v1/notifications/{id}/read + +Mark a single notification as read. Idempotent. + +**Authorization**: Requires authenticated session. `id` must belong to the current user's `user_notifications`. + +**Path parameters**: + +- `id`: UUID of the `user_notifications` record + +**Response (200)**: + +```json +{ + "id": "uuid", + "notificationId": "uuid", + "type": "system", + "title": "Welcome", + "body": "Welcome to the app!", + "payload": { + "action": "none" + }, + "isRead": true, + "readAt": "2026-04-10T01:00:00Z", + "createdAt": "2026-04-10T00:00:00Z" +} +``` + +**Error responses**: + +- 404 `NOTIFICATION_NOT_FOUND`: notification not found or not owned by current user +- Already-read notifications return 200 with current state (idempotent) + +### PATCH /api/v1/notifications/mark-all-read + +Mark all unread notifications for the current user as read. Idempotent. + +**Authorization**: Requires authenticated session. User identity from JWT `sub`. + +**Response (200)**: + +```json +{ + "updatedCount": 3 +} +``` + +Field rules: + +- `updatedCount`: integer `>= 0`, number of notifications that were actually changed from unread to read +- If all notifications are already read, returns `{ "updatedCount": 0 }` +- Only affects notifications where `notifications.status = 'published'` and `notifications.deleted_at IS NULL` + +## Payload + +`payload` is a discriminated union based on the `action` field. + +### action = "none" + +No navigation action on tap. + +```json +{ + "action": "none" +} +``` + +### action = "open_route" + +Navigate to an in-app route. + +```json +{ + "action": "open_route", + "route": "/divination/history", + "entityId": "optional-uuid", + "tab": "optional-tab-name" +} +``` + +Field rules: + +- `route`: required, string, max 200 characters, app-internal route path +- `entityId`: optional, string, max 64 characters, business object ID +- `tab`: optional, string, max 32 characters, sub-page navigation parameter +- `url`: must be absent + +### action = "open_url" + +Open an external URL. + +```json +{ + "action": "open_url", + "url": "https://example.com/page" +} +``` + +Field rules: + +- `url`: required, string, max 500 characters, external URL +- `route`, `entityId`, `tab`: must be absent + +## Error contract linkage + +- RFC7807 + extension `code`, optional `params`. +- Shared registry: `docs/protocols/common/http-error-codes.md`. +- New error codes for this feature are registered in the same registry. \ No newline at end of file