From a940f2ea47c8d87c85f078c8efe2a736a2b07070 Mon Sep 17 00:00:00 2001 From: ZL-Q Date: Tue, 28 Apr 2026 17:20:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(notification):=20=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E5=92=8C=E6=AD=A3=E6=96=87=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=9A=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 通知静态配置支持 title/body i18n - 前端通知列表和详情页展示本地化内容 - 新增数据库迁移脚本 - 更新通知协议文档 --- .../data/apis/notification_api.dart | 37 ++-- .../repositories/notification_repository.dart | 23 ++- .../presentation/bloc/notification_bloc.dart | 33 +++- .../screens/notification_center_screen.dart | 96 +++++++--- .../widgets/notification_list_item.dart | 141 +++++++-------- .../notification_detail_bottom_sheet.dart | 13 +- ...60428_0001_notification_title_body_i18n.py | 51 ++++++ .../core/config/notification/static_schema.py | 26 ++- .../static/notifications/welcome_points.yaml | 12 +- backend/src/models/notification.py | 14 +- backend/src/v1/notifications/router.py | 37 ++-- backend/src/v1/notifications/service.py | 44 ++++- .../tests/unit/test_notification_service.py | 103 +++++++++-- .../unit/test_static_notification_sync.py | 164 +++++++++++++++--- .../notification-inbox-protocol.md | 6 + .../static-notification-sync-protocol.md | 14 +- 16 files changed, 601 insertions(+), 213 deletions(-) create mode 100644 backend/alembic/versions/20260428_0001_notification_title_body_i18n.py diff --git a/apps/lib/features/notifications/data/apis/notification_api.dart b/apps/lib/features/notifications/data/apis/notification_api.dart index 32d08ec..696645f 100644 --- a/apps/lib/features/notifications/data/apis/notification_api.dart +++ b/apps/lib/features/notifications/data/apis/notification_api.dart @@ -15,15 +15,19 @@ class NotificationApi { Future listNotifications({ int limit = 20, String? cursor, + String locale = 'zh', }) async { - final queryParts = ['limit=$limit']; + final queryParameters = {'limit': limit, 'locale': locale}; if (cursor != null) { - queryParts.add('cursor=$cursor'); + queryParameters['cursor'] = cursor; } - final path = '/api/v1/notifications?${queryParts.join("&")}'; try { - final json = await _apiClient.getJson(path); + final response = await _apiClient.rawDio.get>( + '/api/v1/notifications', + queryParameters: queryParameters, + ); + final json = response.data ?? {}; final itemsJson = json['items'] as List? ?? []; final items = itemsJson .map((e) => parseNotificationItem(e as Map)) @@ -59,21 +63,16 @@ class NotificationApi { } } - Future markRead({required String notificationId}) async { - _logger.info( - message: 'Mark read request started', - extra: {'notification_id': notificationId}, - ); + Future markRead({ + required String notificationId, + String locale = 'zh', + }) async { try { final response = await _apiClient.rawDio.patch>( '/api/v1/notifications/$notificationId/read', + queryParameters: {'locale': locale}, ); - final item = parseNotificationItem(response.data!); - _logger.info( - message: 'Mark read request succeeded', - extra: {'notification_id': notificationId, 'is_read': item.isRead}, - ); - return item; + return parseNotificationItem(response.data!); } on DioException catch (error, stackTrace) { _logger.error( message: 'Mark read failed', @@ -85,17 +84,11 @@ class NotificationApi { } Future markAllRead() async { - _logger.info(message: 'Mark all read request started'); try { final response = await _apiClient.rawDio.patch>( '/api/v1/notifications/mark-all-read', ); - final updatedCount = response.data?['updatedCount'] as int? ?? 0; - _logger.info( - message: 'Mark all read request succeeded', - extra: {'updated_count': updatedCount}, - ); - return updatedCount; + return response.data?['updatedCount'] as int? ?? 0; } on DioException catch (error, stackTrace) { _logger.error( message: 'Mark all read failed', diff --git a/apps/lib/features/notifications/data/repositories/notification_repository.dart b/apps/lib/features/notifications/data/repositories/notification_repository.dart index cc6ed8d..d7facf6 100644 --- a/apps/lib/features/notifications/data/repositories/notification_repository.dart +++ b/apps/lib/features/notifications/data/repositories/notification_repository.dart @@ -6,11 +6,15 @@ abstract class NotificationRepository { Future listNotifications({ int limit = 20, String? cursor, + String locale = 'zh', }); Future getUnreadCount(); - Future markRead({required String notificationId}); + Future markRead({ + required String notificationId, + String locale = 'zh', + }); Future markAllRead(); } @@ -25,8 +29,13 @@ class NotificationRepositoryImpl implements NotificationRepository { Future listNotifications({ int limit = 20, String? cursor, + String locale = 'zh', }) async { - return _notificationApi.listNotifications(limit: limit, cursor: cursor); + return _notificationApi.listNotifications( + limit: limit, + cursor: cursor, + locale: locale, + ); } @override @@ -35,8 +44,14 @@ class NotificationRepositoryImpl implements NotificationRepository { } @override - Future markRead({required String notificationId}) async { - return _notificationApi.markRead(notificationId: notificationId); + Future markRead({ + required String notificationId, + String locale = 'zh', + }) async { + return _notificationApi.markRead( + notificationId: notificationId, + locale: locale, + ); } @override diff --git a/apps/lib/features/notifications/presentation/bloc/notification_bloc.dart b/apps/lib/features/notifications/presentation/bloc/notification_bloc.dart index 95ec8fe..a9bd8c5 100644 --- a/apps/lib/features/notifications/presentation/bloc/notification_bloc.dart +++ b/apps/lib/features/notifications/presentation/bloc/notification_bloc.dart @@ -15,6 +15,7 @@ class NotificationState { this.unreadCount = 0, this.hasMore = false, this.nextCursor, + this.isLoadingMore = false, this.errorMessage, }); @@ -23,6 +24,7 @@ class NotificationState { final int unreadCount; final bool hasMore; final String? nextCursor; + final bool isLoadingMore; final String? errorMessage; NotificationState copyWith({ @@ -31,6 +33,7 @@ class NotificationState { int? unreadCount, bool? hasMore, String? nextCursor, + bool? isLoadingMore, String? errorMessage, }) { return NotificationState( @@ -39,6 +42,7 @@ class NotificationState { unreadCount: unreadCount ?? this.unreadCount, hasMore: hasMore ?? this.hasMore, nextCursor: nextCursor ?? this.nextCursor, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, errorMessage: errorMessage ?? this.errorMessage, ); } @@ -81,10 +85,13 @@ final class NotificationRevokedEvent extends NotificationEvent { } class NotificationBloc extends ChangeNotifier { - NotificationBloc({required NotificationRepository repository}) - : _repository = repository; + NotificationBloc({ + required NotificationRepository repository, + this.locale = 'zh', + }) : _repository = repository; final NotificationRepository _repository; + final String locale; final Logger _logger = getLogger('features.notifications.bloc'); NotificationState _state = const NotificationState(); @@ -119,7 +126,10 @@ class NotificationBloc extends ChangeNotifier { notifyListeners(); try { - final result = await _repository.listNotifications(limit: 20); + final result = await _repository.listNotifications( + limit: 20, + locale: locale, + ); _state = _state.copyWith( status: NotificationStatus.loaded, items: result.items, @@ -143,7 +153,10 @@ class NotificationBloc extends ChangeNotifier { Future _refreshNotifications() async { try { - final result = await _repository.listNotifications(limit: 20); + final result = await _repository.listNotifications( + limit: 20, + locale: locale, + ); _state = _state.copyWith( status: NotificationStatus.loaded, items: result.items, @@ -161,18 +174,25 @@ class NotificationBloc extends ChangeNotifier { } Future _loadMore() async { - if (!_state.hasMore || _state.nextCursor == null) return; + if (_state.isLoadingMore || !_state.hasMore || _state.nextCursor == null) { + return; + } + + _state = _state.copyWith(isLoadingMore: true); + notifyListeners(); try { final result = await _repository.listNotifications( limit: 20, cursor: _state.nextCursor, + locale: locale, ); final allItems = [..._state.items, ...result.items]; _state = _state.copyWith( items: allItems, hasMore: result.hasMore, nextCursor: result.nextCursor, + isLoadingMore: false, ); notifyListeners(); } catch (error, stackTrace) { @@ -181,6 +201,8 @@ class NotificationBloc extends ChangeNotifier { error: error, stackTrace: stackTrace, ); + _state = _state.copyWith(isLoadingMore: false); + notifyListeners(); } } @@ -197,6 +219,7 @@ class NotificationBloc extends ChangeNotifier { try { final updated = await _repository.markRead( notificationId: notificationId, + locale: locale, ); final targetIndex = _state.items.indexWhere( (item) => item.id == updated.id, diff --git a/apps/lib/features/notifications/presentation/screens/notification_center_screen.dart b/apps/lib/features/notifications/presentation/screens/notification_center_screen.dart index 9b38c74..c91ada8 100644 --- a/apps/lib/features/notifications/presentation/screens/notification_center_screen.dart +++ b/apps/lib/features/notifications/presentation/screens/notification_center_screen.dart @@ -2,7 +2,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/widgets/app_loading_indicator.dart'; import '../../../../shared/widgets/notification/notification_detail_bottom_sheet.dart'; import '../../data/models/notification_item.dart'; import '../../data/models/notification_payload.dart'; @@ -31,54 +33,93 @@ class NotificationCenterScreen extends StatefulWidget { } class _NotificationCenterScreenState extends State { - late NotificationBloc _bloc; + NotificationBloc? _bloc; + late final ScrollController _scrollController; + + String get _currentLocale { + final locale = Localizations.localeOf(context); + if (locale.scriptCode == 'Hant') return 'zh_Hant'; + return locale.languageCode; + } @override void initState() { super.initState(); - _bloc = NotificationBloc(repository: widget.repository); - _bloc.handleEvent(LoadNotifications()); - _bloc.addListener(_onStateChanged); + _scrollController = ScrollController()..addListener(_onScroll); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_bloc == null) { + _bloc = NotificationBloc( + repository: widget.repository, + locale: _currentLocale, + ); + _bloc!.handleEvent(LoadNotifications()); + _bloc!.addListener(_onStateChanged); + } } void _onStateChanged() { setState(() {}); } + void _onScroll() { + if (!_scrollController.hasClients || _bloc == null) return; + final position = _scrollController.position; + if (position.pixels >= position.maxScrollExtent - 240) { + unawaited(_bloc!.handleEvent(LoadMoreNotifications())); + } + } + @override void dispose() { - _bloc.removeListener(_onStateChanged); - _bloc.dispose(); + _bloc?.removeListener(_onStateChanged); + _bloc?.dispose(); + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final state = _bloc.state; + final l10n = AppLocalizations.of(context)!; + final state = _bloc!.state; return Scaffold( + backgroundColor: colors.surfaceContainerLow, appBar: AppBar( - title: const Text('通知'), + title: Text(l10n.notifyCenterTitle), centerTitle: true, + backgroundColor: colors.surfaceContainerLow, + surfaceTintColor: colors.surfaceContainerLow, actions: [ if (state.items.any((item) => !item.isRead)) TextButton( onPressed: _onMarkAllRead, - child: Text('全部已读', style: TextStyle(color: colors.primary)), + child: Text( + l10n.notifyMarkAllRead, + style: TextStyle(color: colors.primary), + ), ), ], ), body: RefreshIndicator( - onRefresh: () => _bloc.handleEvent(RefreshNotifications()), - child: _buildBody(state, colors), + onRefresh: () => _bloc!.handleEvent(RefreshNotifications()), + child: _buildBody(state, colors, l10n), ), ); } - Widget _buildBody(NotificationState state, ColorScheme colors) { + Widget _buildBody( + NotificationState state, + ColorScheme colors, + AppLocalizations l10n, + ) { if (state.status == NotificationStatus.loading && state.items.isEmpty) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: AppLoadingIndicator()); } if (state.status == NotificationStatus.error && state.items.isEmpty) { @@ -88,11 +129,14 @@ class _NotificationCenterScreenState extends State { children: [ Icon(Icons.error_outline, size: 48, color: colors.error), const SizedBox(height: AppSpacing.md), - Text('加载失败', style: TextStyle(color: colors.onSurfaceVariant)), + Text( + l10n.notifyLoadFailed, + style: TextStyle(color: colors.onSurfaceVariant), + ), const SizedBox(height: AppSpacing.sm), FilledButton( - onPressed: () => _bloc.handleEvent(LoadNotifications()), - child: const Text('重试'), + onPressed: () => _bloc!.handleEvent(LoadNotifications()), + child: Text(l10n.notifyRetry), ), ], ), @@ -115,7 +159,7 @@ class _NotificationCenterScreenState extends State { ), const SizedBox(height: AppSpacing.md), Text( - '暂无通知', + l10n.notifyEmpty, style: TextStyle( color: colors.onSurfaceVariant, fontSize: 16, @@ -130,13 +174,13 @@ class _NotificationCenterScreenState extends State { } return ListView.builder( + controller: _scrollController, 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()), + child: Center(child: AppLoadingIndicator()), ); } final item = state.items[index]; @@ -154,11 +198,13 @@ class _NotificationCenterScreenState extends State { ) async { final wasUnread = !item.isRead; if (!item.isRead) { - await _bloc.handleEvent(MarkNotificationRead(notificationId: item.id)); - final updatedIndex = _bloc.state.items.indexWhere((n) => n.id == item.id); + await _bloc!.handleEvent(MarkNotificationRead(notificationId: item.id)); + final updatedIndex = _bloc!.state.items.indexWhere( + (n) => n.id == item.id, + ); if (wasUnread && updatedIndex >= 0 && - _bloc.state.items[updatedIndex].isRead) { + _bloc!.state.items[updatedIndex].isRead) { await widget.onUnreadCountChanged?.call(); } } @@ -188,9 +234,9 @@ class _NotificationCenterScreenState extends State { } Future _markAllRead() async { - final unreadBefore = _bloc.state.items.any((item) => !item.isRead); - await _bloc.handleEvent(MarkAllNotificationsRead()); - final unreadAfter = _bloc.state.items.any((item) => !item.isRead); + final unreadBefore = _bloc!.state.items.any((item) => !item.isRead); + await _bloc!.handleEvent(MarkAllNotificationsRead()); + final unreadAfter = _bloc!.state.items.any((item) => !item.isRead); if (unreadBefore && !unreadAfter) { await widget.onUnreadCountChanged?.call(); } diff --git a/apps/lib/features/notifications/presentation/widgets/notification_list_item.dart b/apps/lib/features/notifications/presentation/widgets/notification_list_item.dart index 978ddcf..dd21a26 100644 --- a/apps/lib/features/notifications/presentation/widgets/notification_list_item.dart +++ b/apps/lib/features/notifications/presentation/widgets/notification_list_item.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/utils/time_format.dart'; import '../../data/models/notification_item.dart'; class NotificationListItem extends StatelessWidget { @@ -18,90 +19,76 @@ class NotificationListItem extends StatelessWidget { final colors = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - return IntrinsicHeight( - child: 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, - ), + 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, - ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!item.isRead) + Container( + margin: const EdgeInsets.only( + top: AppSpacing.sm, + right: AppSpacing.sm, ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - 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, - ), - ), - ], + width: 8, + height: 8, + decoration: BoxDecoration( + color: colors.primary, + shape: BoxShape.circle, ), ), - ], - ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + 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( + formatRelativeTime(context, item.createdAt), + style: textTheme.labelSmall?.copyWith( + color: colors.outline, + ), + ), + ], + ), + ), + ], ), ), ); } - - String _formatTime(DateTime dt) { - final now = DateTime.now(); - final diff = now.difference(dt); - if (diff.inMinutes < 1) return '刚刚'; - if (diff.inHours < 1) return '${diff.inMinutes}分钟前'; - if (diff.inDays < 1) return '${diff.inHours}小时前'; - if (diff.inDays < 30) return '${diff.inDays}天前'; - return '${dt.month}/${dt.day}'; - } } diff --git a/apps/lib/shared/widgets/notification/notification_detail_bottom_sheet.dart b/apps/lib/shared/widgets/notification/notification_detail_bottom_sheet.dart index 04b5761..b81c0a3 100644 --- a/apps/lib/shared/widgets/notification/notification_detail_bottom_sheet.dart +++ b/apps/lib/shared/widgets/notification/notification_detail_bottom_sheet.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../../features/notifications/data/models/notification_item.dart'; import '../../theme/design_tokens.dart'; +import '../../utils/time_format.dart'; class NotificationDetailBottomSheet extends StatefulWidget { const NotificationDetailBottomSheet({ @@ -76,7 +77,7 @@ class _NotificationDetailBottomSheetState Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), child: Text( - _formatTime(widget.item.createdAt), + formatRelativeTime(context, widget.item.createdAt), style: textTheme.labelSmall?.copyWith(color: colors.outline), ), ), @@ -97,16 +98,6 @@ class _NotificationDetailBottomSheetState ), ); } - - 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}'; - } } Future showNotificationDetailBottomSheet({ diff --git a/backend/alembic/versions/20260428_0001_notification_title_body_i18n.py b/backend/alembic/versions/20260428_0001_notification_title_body_i18n.py new file mode 100644 index 0000000..427e2e5 --- /dev/null +++ b/backend/alembic/versions/20260428_0001_notification_title_body_i18n.py @@ -0,0 +1,51 @@ +"""Convert notification title/body from text to jsonb (i18n dict). + +title and body become jsonb objects keyed by locale code: + {"zh": "欢迎来到觅爻", "zh_Hant": "...", "en": "..."} + +Existing data is wrapped under the "zh" key (simplified Chinese default). + +Revision ID: 20260428_0001 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "20260428_0001" +down_revision = "20260427_0002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + """ + ALTER TABLE notifications + ALTER COLUMN title TYPE jsonb USING jsonb_build_object('zh', title), + ALTER COLUMN body TYPE jsonb USING jsonb_build_object('zh', body); + """ + ) + + op.execute( + """ + ALTER TABLE notifications DROP CONSTRAINT IF EXISTS ck_notifications_payload_object; + """ + ) + + op.execute( + """ + ALTER TABLE notifications + ADD CONSTRAINT ck_notifications_payload_object + CHECK (jsonb_typeof(payload) = 'object'); + """ + ) + + +def downgrade() -> None: + op.execute( + """ + ALTER TABLE notifications + ALTER COLUMN title TYPE text USING COALESCE(title ->> 'zh', ''), + ALTER COLUMN body TYPE text USING COALESCE(body ->> 'zh', ''); + """ + ) diff --git a/backend/src/core/config/notification/static_schema.py b/backend/src/core/config/notification/static_schema.py index 0479012..05b1259 100644 --- a/backend/src/core/config/notification/static_schema.py +++ b/backend/src/core/config/notification/static_schema.py @@ -7,7 +7,14 @@ from typing import Literal from uuid import UUID import yaml -from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + ValidationError, + field_validator, + model_validator, +) from backend.src.schemas.shared.notification import ( NotificationPayload, @@ -18,6 +25,7 @@ from schemas.enums import NotificationTargetMode class StaticNotificationDefinition(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + supported_locale_keys: ClassVar[set[str]] = {"zh", "zh_Hant", "en"} source_key: str = Field(min_length=1, max_length=128) version: int = Field(ge=1) @@ -25,10 +33,22 @@ class StaticNotificationDefinition(BaseModel): status: Literal["draft", "published", "revoked"] deleted: bool = False published_at: datetime | None = None - title: str = Field(min_length=1) - body: str = Field(min_length=1) + title: dict[str, str] = Field(min_length=1) + body: dict[str, str] = Field(min_length=1) payload: NotificationPayload = NotificationPayloadNone(action="none") + @field_validator("title", "body") + @classmethod + def validate_i18n_text(cls, value: dict[str, str]) -> dict[str, str]: + invalid_keys = set(value) - cls.supported_locale_keys + if invalid_keys: + raise ValueError("i18n keys must be one of zh, zh_Hant, en") + if "zh" not in value: + raise ValueError("i18n text must include zh") + if any(not text.strip() for text in value.values()): + raise ValueError("i18n text values must be non-empty") + return value + class StaticNotificationTargets(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") diff --git a/backend/src/core/config/static/notifications/welcome_points.yaml b/backend/src/core/config/static/notifications/welcome_points.yaml index d6c4a61..b9e2da1 100644 --- a/backend/src/core/config/static/notifications/welcome_points.yaml +++ b/backend/src/core/config/static/notifications/welcome_points.yaml @@ -1,10 +1,16 @@ notification: source_key: welcome_points - version: 1 + version: 2 type: system status: published - title: 欢迎来到觅爻 - body: 你已获得新用户奖励,点击前往积分页查看当前余额。 + title: + zh: 欢迎来到觅爻 + zh_Hant: 歡迎來到覓爻 + en: Welcome to MeiYao + body: + zh: 你已获得新用户奖励,点击前往积分页查看当前余额。 + zh_Hant: 你已獲得新用戶獎勵,點擊前往積分頁查看當前餘額。 + en: You have received a new user reward. Tap to check your points balance. payload: action: open_route route: /points diff --git a/backend/src/models/notification.py b/backend/src/models/notification.py index db5a976..c00c927 100644 --- a/backend/src/models/notification.py +++ b/backend/src/models/notification.py @@ -3,12 +3,12 @@ from __future__ import annotations import uuid from datetime import datetime -from sqlalchemy import CheckConstraint, DateTime, Index, String, Text, text +from sqlalchemy import CheckConstraint, DateTime, Index, String, 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 +from core.db.types import json_jsonb as jsonb from schemas.enums import NotificationTargetMode @@ -57,10 +57,14 @@ class Notification(TimestampMixin, SoftDeleteMixin, Base): source_key: Mapped[str | None] = mapped_column(String(128), nullable=True) source_version: Mapped[int | None] = mapped_column(nullable=True) content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) - title: Mapped[str] = mapped_column(Text, nullable=False) - body: Mapped[str] = mapped_column(Text, nullable=False) + title: Mapped[dict[str, str]] = mapped_column( + jsonb, nullable=False, server_default=text("'{}'::jsonb"), default=dict, + ) + body: Mapped[dict[str, str]] = mapped_column( + jsonb, nullable=False, server_default=text("'{}'::jsonb"), default=dict, + ) payload: Mapped[dict[str, object]] = mapped_column( - json_jsonb, + jsonb, nullable=False, server_default=text("'{}'::jsonb"), default=dict, diff --git a/backend/src/v1/notifications/router.py b/backend/src/v1/notifications/router.py index 35418cb..0051c2c 100644 --- a/backend/src/v1/notifications/router.py +++ b/backend/src/v1/notifications/router.py @@ -1,11 +1,13 @@ from __future__ import annotations +from datetime import datetime from typing import Annotated from fastapi import APIRouter, Depends, Query from core.logging import get_logger from core.auth.models import CurrentUser +from core.http.errors import ApiProblemError, problem_payload from v1.notifications.dependencies import get_notification_service from v1.notifications.schemas import ( MarkAllReadResponse, @@ -13,33 +15,42 @@ from v1.notifications.schemas import ( NotificationListResponse, UnreadCountResponse, ) -from v1.notifications.service import NotificationService +from v1.notifications.service import NotificationService, normalize_locale from v1.users.dependencies import get_current_user router = APIRouter(prefix="/notifications", tags=["notifications"]) logger = get_logger("v1.notifications.router") +def _parse_cursor(cursor: str | None) -> datetime | None: + if cursor is None: + return None + try: + return datetime.fromisoformat(cursor.replace("Z", "+00:00")) + except ValueError as exc: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="NOTIFICATION_INVALID_CURSOR", + detail="Notification cursor must be an ISO 8601 datetime", + params={"cursor": cursor}, + ), + ) from exc + + @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), + locale: 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, + cursor=_parse_cursor(cursor), + locale=normalize_locale(locale), ) logger.info( "Notification list fetched", @@ -89,14 +100,13 @@ async def mark_notification_read( notification_id: str, service: Annotated[NotificationService, Depends(get_notification_service)], current_user: Annotated[CurrentUser, Depends(get_current_user)], + locale: str | None = Query(default=None), ) -> 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( @@ -108,6 +118,7 @@ async def mark_notification_read( item = await service.mark_read( user_notification_id=uid, user_id=current_user.id, + locale=normalize_locale(locale), ) logger.info( "Notification marked as read", diff --git a/backend/src/v1/notifications/service.py b/backend/src/v1/notifications/service.py index 3c48537..2136713 100644 --- a/backend/src/v1/notifications/service.py +++ b/backend/src/v1/notifications/service.py @@ -13,6 +13,35 @@ from v1.notifications.schemas import ( NotificationPayload, ) +DEFAULT_LOCALE = "zh" +SUPPORTED_LOCALES = frozenset({"zh", "zh_Hant", "en"}) + + +def resolve_i18n_text(i18n_dict: dict[str, str], locale: str) -> str: + if not i18n_dict: + return "" + if locale in i18n_dict: + return i18n_dict[locale] + if DEFAULT_LOCALE in i18n_dict: + return i18n_dict[DEFAULT_LOCALE] + return "" + + +def normalize_locale(raw: str | None) -> str: + if raw is None: + return DEFAULT_LOCALE + locale = raw.strip() + if locale in SUPPORTED_LOCALES: + return locale + lower = locale.lower().replace("-", "_") + if lower in ("zh_cn", "zh_hans", "zh"): + return "zh" + if lower in ("zh_tw", "zh_hant", "zh_hk"): + return "zh_Hant" + if lower.startswith("en"): + return "en" + return DEFAULT_LOCALE + @dataclass(frozen=True) class NotificationListItem: @@ -44,6 +73,7 @@ class NotificationService: user_id: UUID, limit: int = 20, cursor: datetime | None = None, + locale: str = DEFAULT_LOCALE, ) -> NotificationListResult: actual_limit = min(limit, 50) rows = await self._repository.list_notifications( @@ -65,8 +95,8 @@ class NotificationService: id=un.id, notification_id=n.id, type=n.type, - title=n.title, - body=n.body, + title=resolve_i18n_text(n.title, locale), + body=resolve_i18n_text(n.body, locale), payload=payload, is_read=un.is_read, read_at=un.read_at, @@ -83,7 +113,11 @@ class NotificationService: return await self._repository.get_unread_count(user_id=user_id) async def mark_read( - self, *, user_notification_id: UUID, user_id: UUID + self, + *, + user_notification_id: UUID, + user_id: UUID, + locale: str = DEFAULT_LOCALE, ) -> NotificationListItem: result = await self._repository.get_user_notification( user_notification_id=user_notification_id, @@ -109,8 +143,8 @@ class NotificationService: id=un.id, notification_id=n.id, type=n.type, - title=n.title, - body=n.body, + title=resolve_i18n_text(n.title, locale), + body=resolve_i18n_text(n.body, locale), payload=payload, is_read=True, read_at=un.read_at or datetime.now(timezone.utc), diff --git a/backend/tests/unit/test_notification_service.py b/backend/tests/unit/test_notification_service.py index d16410d..df895ac 100644 --- a/backend/tests/unit/test_notification_service.py +++ b/backend/tests/unit/test_notification_service.py @@ -5,7 +5,12 @@ from uuid import UUID, uuid4 import pytest -from v1.notifications.service import NotificationService, _parse_payload +from v1.notifications.service import ( + NotificationService, + _parse_payload, + resolve_i18n_text, + normalize_locale, +) from v1.notifications.schemas import ( NotificationPayloadNone, NotificationPayloadRoute, @@ -39,8 +44,8 @@ class _FakeNotification: *, id: UUID, type: str = "system", - title: str = "Test", - body: str = "Test body", + title: dict[str, str] | None = None, + body: dict[str, str] | None = None, payload: dict | None = None, status: str = "published", deleted_at: datetime | None = None, @@ -48,8 +53,8 @@ class _FakeNotification: ): self.id = id self.type = type - self.title = title - self.body = body + self.title = title or {"zh": "Test"} + self.body = body or {"zh": "Test body"} self.payload = payload or {"action": "none"} self.status = status self.deleted_at = deleted_at @@ -154,8 +159,8 @@ def _make_notification( notification_id: UUID | None = None, is_read: bool = False, read_at: datetime | None = None, - title: str = "Test", - body: str = "Test body", + title: dict[str, str] | None = None, + body: dict[str, str] | None = None, payload: dict | None = None, status: str = "published", deleted_at: datetime | None = None, @@ -185,8 +190,12 @@ class TestListNotifications: 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") + un_a, n_a = _make_notification( + user_id=USER_A, title={"zh": "A1"}, body={"zh": "A1 body"}, + ) + un_b, n_b = _make_notification( + user_id=USER_B, title={"zh": "B1"}, body={"zh": "B1 body"}, + ) fake_repo.add_item(un_a, n_a) fake_repo.add_item(un_b, n_b) @@ -219,7 +228,9 @@ class TestListNotifications: self, service: NotificationService, fake_repo: _FakeNotificationRepository ): for i in range(3): - un, n = _make_notification(user_id=USER_A, title=f"N{i}") + un, n = _make_notification( + user_id=USER_A, title={"zh": f"N{i}"}, body={"zh": f"N{i} body"}, + ) fake_repo.add_item(un, n) result = await service.list_notifications(user_id=USER_A, limit=2) @@ -383,3 +394,75 @@ class TestParsePayload: assert payload.route == "/settings" assert payload.entity_id is None assert payload.tab is None + + +class TestResolveI18nText: + def test_exact_locale_match(self): + text = resolve_i18n_text({"zh": "你好", "en": "Hello"}, "en") + assert text == "Hello" + + def test_falls_back_to_default(self): + text = resolve_i18n_text({"zh": "你好", "en": "Hello"}, "zh_Hant") + assert text == "你好" + + def test_returns_empty_when_default_missing(self): + text = resolve_i18n_text({"en": "Hello"}, "zh_Hant") + assert text == "" + + def test_empty_dict(self): + text = resolve_i18n_text({}, "en") + assert text == "" + + +class TestNormalizeLocale: + def test_known_locale_passthrough(self): + assert normalize_locale("zh") == "zh" + assert normalize_locale("zh_Hant") == "zh_Hant" + assert normalize_locale("en") == "en" + + def test_none_returns_default(self): + assert normalize_locale(None) == "zh" + + def test_zh_cn_maps_to_zh(self): + assert normalize_locale("zh_CN") == "zh" + assert normalize_locale("zh_Hans") == "zh" + + def test_zh_tw_maps_to_hant(self): + assert normalize_locale("zh_TW") == "zh_Hant" + assert normalize_locale("zh-Hant") == "zh_Hant" + + def test_unknown_returns_default(self): + assert normalize_locale("fr") == "zh" + assert normalize_locale("ja") == "zh" + + +class TestListNotificationsI18n: + @pytest.mark.asyncio + async def test_locale_en_returns_english( + self, service: NotificationService, fake_repo: _FakeNotificationRepository + ): + un, n = _make_notification( + user_id=USER_A, + title={"zh": "你好", "en": "Hello"}, + body={"zh": "正文", "en": "Body"}, + ) + fake_repo.add_item(un, n) + + result = await service.list_notifications(user_id=USER_A, locale="en") + assert result.items[0].title == "Hello" + assert result.items[0].body == "Body" + + @pytest.mark.asyncio + async def test_locale_zh_hant_falls_back_to_zh( + self, service: NotificationService, fake_repo: _FakeNotificationRepository + ): + un, n = _make_notification( + user_id=USER_A, + title={"zh": "你好", "en": "Hello"}, + body={"zh": "正文", "en": "Body"}, + ) + fake_repo.add_item(un, n) + + result = await service.list_notifications(user_id=USER_A, locale="zh_Hant") + assert result.items[0].title == "你好" + assert result.items[0].body == "正文" diff --git a/backend/tests/unit/test_static_notification_sync.py b/backend/tests/unit/test_static_notification_sync.py index 2267624..a2ead40 100644 --- a/backend/tests/unit/test_static_notification_sync.py +++ b/backend/tests/unit/test_static_notification_sync.py @@ -27,8 +27,12 @@ def test_load_static_notification_file_parses_valid_yaml(tmp_path: Path) -> None version: 1 type: system status: published - title: Welcome - body: Welcome to the app. + title: + zh: 欢迎 + en: Welcome + body: + zh: 欢迎使用 + en: Welcome to the app. payload: action: open_route route: /points @@ -43,6 +47,8 @@ def test_load_static_notification_file_parses_valid_yaml(tmp_path: Path) -> None loaded = load_static_notification_file(file_path) assert loaded.notification.source_key == "welcome_bonus" + assert loaded.notification.title == {"zh": "欢迎", "en": "Welcome"} + assert loaded.notification.body == {"zh": "欢迎使用", "en": "Welcome to the app."} assert loaded.notification.payload.action == "open_route" assert loaded.targets.mode == NotificationTargetMode.USER_IDS assert len(loaded.targets.user_ids or []) == 1 @@ -58,8 +64,10 @@ def test_load_static_notification_file_parses_new_users(tmp_path: Path) -> None: version: 1 type: system status: published - title: Welcome - body: You got points. + title: + zh: 欢迎 + body: + zh: 你好 payload: action: open_route route: /points @@ -85,8 +93,10 @@ def test_load_static_notification_file_parses_exist_users(tmp_path: Path) -> Non version: 1 type: system status: published - title: Come back - body: We miss you. + title: + zh: 回来吧 + body: + zh: 想你 payload: action: none targets: @@ -110,8 +120,10 @@ def test_load_static_notification_file_parses_all_users(tmp_path: Path) -> None: version: 1 type: system status: published - title: Announcement - body: Maintenance at midnight. + title: + zh: 公告 + body: + zh: 午夜维护 payload: action: none targets: @@ -134,8 +146,10 @@ def test_load_static_notification_file_rejects_invalid_targets(tmp_path: Path) - version: 1 type: system status: published - title: Invalid - body: Invalid targets. + title: + zh: 无效 + body: + zh: 无效 payload: action: none targets: @@ -149,6 +163,88 @@ def test_load_static_notification_file_rejects_invalid_targets(tmp_path: Path) - load_static_notification_file(file_path) +def test_load_static_notification_file_rejects_i18n_without_zh( + tmp_path: Path, +) -> None: + file_path = tmp_path / "missing_zh.yaml" + _write_yaml( + file_path, + """ + notification: + source_key: missing_zh + version: 1 + type: system + status: published + title: + en: Welcome + body: + zh: 正文 + payload: + action: none + targets: + mode: all_users + """, + ) + + with pytest.raises(ValueError, match="Invalid static notification data"): + load_static_notification_file(file_path) + + +def test_load_static_notification_file_rejects_empty_i18n_text( + tmp_path: Path, +) -> None: + file_path = tmp_path / "empty_i18n.yaml" + _write_yaml( + file_path, + """ + notification: + source_key: empty_i18n + version: 1 + type: system + status: published + title: + zh: "" + body: + zh: 正文 + payload: + action: none + targets: + mode: all_users + """, + ) + + with pytest.raises(ValueError, match="Invalid static notification data"): + load_static_notification_file(file_path) + + +def test_load_static_notification_file_rejects_unknown_i18n_locale( + tmp_path: Path, +) -> None: + file_path = tmp_path / "unknown_locale.yaml" + _write_yaml( + file_path, + """ + notification: + source_key: unknown_locale + version: 1 + type: system + status: published + title: + zh: 标题 + ja: タイトル + body: + zh: 正文 + payload: + action: none + targets: + mode: all_users + """, + ) + + with pytest.raises(ValueError, match="Invalid static notification data"): + load_static_notification_file(file_path) + + def test_load_static_notification_file_rejects_unknown_mode(tmp_path: Path) -> None: file_path = tmp_path / "bad_mode.yaml" _write_yaml( @@ -159,8 +255,10 @@ def test_load_static_notification_file_rejects_unknown_mode(tmp_path: Path) -> N version: 1 type: system status: published - title: Bad - body: Bad mode. + title: + zh: 坏 + body: + zh: 坏 payload: action: none targets: @@ -184,8 +282,10 @@ def test_load_static_notification_file_rejects_new_users_with_user_ids( version: 1 type: system status: published - title: Bad - body: Bad. + title: + zh: 坏 + body: + zh: 坏 payload: action: none targets: @@ -211,8 +311,10 @@ def test_load_static_notification_file_rejects_user_ids_without_list( version: 1 type: system status: published - title: Bad - body: Bad. + title: + zh: 坏 + body: + zh: 坏 payload: action: none targets: @@ -235,8 +337,10 @@ def test_load_static_notification_documents_rejects_duplicate_source_key( version: 1 type: system status: published - title: First - body: First body. + title: + zh: 第一 + body: + zh: 第一 payload: action: none targets: @@ -251,8 +355,10 @@ def test_load_static_notification_documents_rejects_duplicate_source_key( version: 1 type: system status: published - title: Second - body: Second body. + title: + zh: 第二 + body: + zh: 第二 payload: action: none targets: @@ -275,8 +381,10 @@ def test_content_hash_changes_when_notification_changes(tmp_path: Path) -> None: version: 1 type: system status: published - title: Title A - body: Body A. + title: + zh: 标题A + body: + zh: 正文A payload: action: none targets: @@ -291,8 +399,10 @@ def test_content_hash_changes_when_notification_changes(tmp_path: Path) -> None: version: 1 type: system status: published - title: Title B - body: Body A. + title: + zh: 标题B + body: + zh: 正文A payload: action: none targets: @@ -319,8 +429,10 @@ def test_load_static_notification_file_supports_deleted_flag(tmp_path: Path) -> type: system status: revoked deleted: true - title: Deleted - body: Deleted body. + title: + zh: 已删 + body: + zh: 已删 payload: action: none targets: diff --git a/docs/protocols/notification/notification-inbox-protocol.md b/docs/protocols/notification/notification-inbox-protocol.md index 6300467..76078ff 100644 --- a/docs/protocols/notification/notification-inbox-protocol.md +++ b/docs/protocols/notification/notification-inbox-protocol.md @@ -27,6 +27,7 @@ List notifications for the current user. - `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`) +- `locale` (optional, string): requested locale for title/body resolution. Supported values: `zh` (default), `zh_Hant`, `en`. If the requested locale is not available in the notification's i18n dict, falls back to `zh`. **Response (200)**: @@ -61,6 +62,7 @@ Field rules: - `payload`: discriminated union (see Payload section below) - `isRead`: boolean - `readAt`: ISO 8601 timestamp or `null` +- `title` and `body`: resolved plain strings based on the `locale` parameter. The database stores these as i18n JSONB objects (`{"zh": "...", "zh_Hant": "...", "en": "..."}`); the API resolves the best match before returning. - Results are filtered: `notifications.status = 'published'` and `notifications.deleted_at IS NULL` ### GET /api/v1/notifications/unread-count @@ -92,6 +94,10 @@ Mark a single notification as read. Idempotent. - `notification_id`: UUID of the `user_notifications` record +**Query parameters**: + +- `locale` (optional, string): requested locale for title/body resolution (same rules as list endpoint) + **Response (200)**: ```json diff --git a/docs/protocols/notification/static-notification-sync-protocol.md b/docs/protocols/notification/static-notification-sync-protocol.md index 7fe1bc8..c1b622e 100644 --- a/docs/protocols/notification/static-notification-sync-protocol.md +++ b/docs/protocols/notification/static-notification-sync-protocol.md @@ -41,8 +41,14 @@ notification: type: system status: published published_at: 2026-04-10T08:00:00Z - title: 新用户欢迎通知 - body: 你已获得注册奖励,可前往积分中心查看。 + title: + zh: 新用户欢迎通知 + zh_Hant: 新用戶歡迎通知 + en: Welcome + body: + zh: 你已获得注册奖励,可前往积分中心查看。 + zh_Hant: 你已獲得註冊獎勵,可前往積分中心查看。 + en: You have received a registration reward. Check your points. payload: action: open_route route: /points @@ -60,8 +66,8 @@ targets: - `status`: required, one of `draft`, `published`, `revoked` - `deleted`: optional, boolean, default `false`, soft-delete this notification - `published_at`: optional ISO 8601 timestamp -- `title`: required, non-empty string -- `body`: required, non-empty string +- `title`: required, non-empty dict mapping locale codes to translated strings. Must include at least `zh`. Supported keys: `zh`, `zh_Hant`, `en`. +- `body`: required, non-empty dict mapping locale codes to translated strings. Must include at least `zh`. Supported keys: `zh`, `zh_Hant`, `en`. - `payload`: required, must follow the notification payload protocol ### targets