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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user