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,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>());
});
});
}