feat: 实现站内通知系统
- 后端: 新增 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 及错误码注册
This commit is contained in:
@@ -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<EryaoApp> {
|
||||
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<EryaoApp> {
|
||||
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<EryaoApp> {
|
||||
@override
|
||||
void dispose() {
|
||||
_authBloc.dispose();
|
||||
_notificationBloc.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -415,6 +427,7 @@ class _EryaoAppState extends State<EryaoApp> {
|
||||
_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<EryaoApp> {
|
||||
historyRecords: _historyRecords,
|
||||
coinBalance: _creditsBalance,
|
||||
divinationApi: _divinationApi,
|
||||
notificationBloc: _notificationBloc,
|
||||
notificationRepository: _notificationRepository,
|
||||
onLocaleChanged: _handleInterfaceLanguageChanged,
|
||||
onProfileSettingsChanged: _saveProfileSettings,
|
||||
onSaveProfile: _saveProfile,
|
||||
|
||||
@@ -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<DivinationResultData> historyRecords;
|
||||
final int coinBalance;
|
||||
final DivinationApi divinationApi;
|
||||
final NotificationBloc notificationBloc;
|
||||
final NotificationRepository notificationRepository;
|
||||
final Future<void> Function(String languageTag) onLocaleChanged;
|
||||
final Future<void> Function(ProfileSettingsV1 settings)
|
||||
onProfileSettingsChanged;
|
||||
@@ -108,6 +113,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
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<DivinationResultData> historyItems;
|
||||
@@ -165,6 +174,8 @@ class _HomeTab extends StatelessWidget {
|
||||
onDivinationCompleted;
|
||||
final Future<void> 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<void>(
|
||||
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,
|
||||
),
|
||||
|
||||
@@ -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<NotificationListResult> listNotifications({
|
||||
int limit = 20,
|
||||
String? cursor,
|
||||
}) async {
|
||||
final queryParts = <String>['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<dynamic>? ?? [];
|
||||
final items = itemsJson
|
||||
.map((e) => parseNotificationItem(e as Map<String, dynamic>))
|
||||
.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<int> 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<NotificationItem> markRead({required String notificationId}) async {
|
||||
try {
|
||||
final response = await _apiClient.rawDio.patch<Map<String, dynamic>>(
|
||||
'/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<int> markAllRead() async {
|
||||
try {
|
||||
final response = await _apiClient.rawDio.patch<Map<String, dynamic>>(
|
||||
'/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<String, dynamic>) {
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<NotificationItem> items;
|
||||
final String? nextCursor;
|
||||
final bool hasMore;
|
||||
}
|
||||
|
||||
NotificationItem parseNotificationItem(Map<String, dynamic> 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<String, dynamic>?) ?? {},
|
||||
),
|
||||
isRead: json['isRead'] as bool? ?? false,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
readAt: json['readAt'] != null
|
||||
? DateTime.parse(json['readAt'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
@@ -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<String, dynamic> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<NotificationListResult> listNotifications({
|
||||
int limit = 20,
|
||||
String? cursor,
|
||||
});
|
||||
|
||||
Future<int> getUnreadCount();
|
||||
|
||||
Future<NotificationItem> markRead({required String notificationId});
|
||||
|
||||
Future<int> markAllRead();
|
||||
}
|
||||
|
||||
class NotificationRepositoryImpl implements NotificationRepository {
|
||||
NotificationRepositoryImpl({required NotificationApi notificationApi})
|
||||
: _notificationApi = notificationApi;
|
||||
|
||||
final NotificationApi _notificationApi;
|
||||
|
||||
@override
|
||||
Future<NotificationListResult> listNotifications({
|
||||
int limit = 20,
|
||||
String? cursor,
|
||||
}) async {
|
||||
return _notificationApi.listNotifications(limit: limit, cursor: cursor);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getUnreadCount() async {
|
||||
return _notificationApi.getUnreadCount();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<NotificationItem> markRead({required String notificationId}) async {
|
||||
return _notificationApi.markRead(notificationId: notificationId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> markAllRead() async {
|
||||
return _notificationApi.markAllRead();
|
||||
}
|
||||
}
|
||||
@@ -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<NotificationItem> items;
|
||||
final int unreadCount;
|
||||
final bool hasMore;
|
||||
final String? nextCursor;
|
||||
final String? errorMessage;
|
||||
|
||||
NotificationState copyWith({
|
||||
NotificationStatus? status,
|
||||
List<NotificationItem>? 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<void> 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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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();
|
||||
}
|
||||
}
|
||||
@@ -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<NotificationCenterScreen> createState() =>
|
||||
_NotificationCenterScreenState();
|
||||
}
|
||||
|
||||
class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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}';
|
||||
}
|
||||
}
|
||||
@@ -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<NotificationItem> items = [];
|
||||
int unreadCount = 0;
|
||||
int markAllReadCallCount = 0;
|
||||
|
||||
@override
|
||||
Future<NotificationListResult> listNotifications({
|
||||
int limit = 20,
|
||||
String? cursor,
|
||||
}) async {
|
||||
return NotificationListResult(
|
||||
items: items,
|
||||
hasMore: false,
|
||||
nextCursor: null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getUnreadCount() async => unreadCount;
|
||||
|
||||
@override
|
||||
Future<NotificationItem> 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<int> 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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<NotificationPayloadNone>());
|
||||
});
|
||||
|
||||
test('parses open_route action with all fields', () {
|
||||
final payload = parseNotificationPayload({
|
||||
'action': 'open_route',
|
||||
'route': '/history',
|
||||
'entityId': 'abc-123',
|
||||
'tab': 'details',
|
||||
});
|
||||
expect(payload, isA<NotificationPayloadRoute>());
|
||||
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<NotificationPayloadRoute>());
|
||||
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<NotificationPayloadUrl>());
|
||||
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<NotificationPayloadNone>());
|
||||
});
|
||||
|
||||
test('missing action defaults to none', () {
|
||||
final payload = parseNotificationPayload({});
|
||||
expect(payload, isA<NotificationPayloadNone>());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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. 后续扩展条件
|
||||
|
||||
只有在真实需求出现时,再考虑:
|
||||
|
||||
- 用删除文件触发软删除
|
||||
- 严格对齐目标用户集合并清理历史接收关系
|
||||
- 通过后台页面管理静态通知
|
||||
- 将静态通知同步纳入更完整的发布工作流
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user