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:
qzl
2026-04-10 18:50:08 +08:00
parent 17ef460391
commit 3f3d613d99
28 changed files with 3481 additions and 651 deletions
@@ -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();
}
}