feat(notification): 通知标题和正文支持多语言

- 通知静态配置支持 title/body i18n
- 前端通知列表和详情页展示本地化内容
- 新增数据库迁移脚本
- 更新通知协议文档
This commit is contained in:
ZL-Q
2026-04-28 17:20:17 +08:00
parent b9617ae152
commit a940f2ea47
16 changed files with 601 additions and 213 deletions
@@ -15,15 +15,19 @@ class NotificationApi {
Future<NotificationListResult> listNotifications({ Future<NotificationListResult> listNotifications({
int limit = 20, int limit = 20,
String? cursor, String? cursor,
String locale = 'zh',
}) async { }) async {
final queryParts = <String>['limit=$limit']; final queryParameters = <String, Object>{'limit': limit, 'locale': locale};
if (cursor != null) { if (cursor != null) {
queryParts.add('cursor=$cursor'); queryParameters['cursor'] = cursor;
} }
final path = '/api/v1/notifications?${queryParts.join("&")}';
try { try {
final json = await _apiClient.getJson(path); final response = await _apiClient.rawDio.get<Map<String, dynamic>>(
'/api/v1/notifications',
queryParameters: queryParameters,
);
final json = response.data ?? <String, dynamic>{};
final itemsJson = json['items'] as List<dynamic>? ?? []; final itemsJson = json['items'] as List<dynamic>? ?? [];
final items = itemsJson final items = itemsJson
.map((e) => parseNotificationItem(e as Map<String, dynamic>)) .map((e) => parseNotificationItem(e as Map<String, dynamic>))
@@ -59,21 +63,16 @@ class NotificationApi {
} }
} }
Future<NotificationItem> markRead({required String notificationId}) async { Future<NotificationItem> markRead({
_logger.info( required String notificationId,
message: 'Mark read request started', String locale = 'zh',
extra: {'notification_id': notificationId}, }) async {
);
try { try {
final response = await _apiClient.rawDio.patch<Map<String, dynamic>>( final response = await _apiClient.rawDio.patch<Map<String, dynamic>>(
'/api/v1/notifications/$notificationId/read', '/api/v1/notifications/$notificationId/read',
queryParameters: {'locale': locale},
); );
final item = parseNotificationItem(response.data!); return parseNotificationItem(response.data!);
_logger.info(
message: 'Mark read request succeeded',
extra: {'notification_id': notificationId, 'is_read': item.isRead},
);
return item;
} on DioException catch (error, stackTrace) { } on DioException catch (error, stackTrace) {
_logger.error( _logger.error(
message: 'Mark read failed', message: 'Mark read failed',
@@ -85,17 +84,11 @@ class NotificationApi {
} }
Future<int> markAllRead() async { Future<int> markAllRead() async {
_logger.info(message: 'Mark all read request started');
try { try {
final response = await _apiClient.rawDio.patch<Map<String, dynamic>>( final response = await _apiClient.rawDio.patch<Map<String, dynamic>>(
'/api/v1/notifications/mark-all-read', '/api/v1/notifications/mark-all-read',
); );
final updatedCount = response.data?['updatedCount'] as int? ?? 0; return response.data?['updatedCount'] as int? ?? 0;
_logger.info(
message: 'Mark all read request succeeded',
extra: {'updated_count': updatedCount},
);
return updatedCount;
} on DioException catch (error, stackTrace) { } on DioException catch (error, stackTrace) {
_logger.error( _logger.error(
message: 'Mark all read failed', message: 'Mark all read failed',
@@ -6,11 +6,15 @@ abstract class NotificationRepository {
Future<NotificationListResult> listNotifications({ Future<NotificationListResult> listNotifications({
int limit = 20, int limit = 20,
String? cursor, String? cursor,
String locale = 'zh',
}); });
Future<int> getUnreadCount(); Future<int> getUnreadCount();
Future<NotificationItem> markRead({required String notificationId}); Future<NotificationItem> markRead({
required String notificationId,
String locale = 'zh',
});
Future<int> markAllRead(); Future<int> markAllRead();
} }
@@ -25,8 +29,13 @@ class NotificationRepositoryImpl implements NotificationRepository {
Future<NotificationListResult> listNotifications({ Future<NotificationListResult> listNotifications({
int limit = 20, int limit = 20,
String? cursor, String? cursor,
String locale = 'zh',
}) async { }) async {
return _notificationApi.listNotifications(limit: limit, cursor: cursor); return _notificationApi.listNotifications(
limit: limit,
cursor: cursor,
locale: locale,
);
} }
@override @override
@@ -35,8 +44,14 @@ class NotificationRepositoryImpl implements NotificationRepository {
} }
@override @override
Future<NotificationItem> markRead({required String notificationId}) async { Future<NotificationItem> markRead({
return _notificationApi.markRead(notificationId: notificationId); required String notificationId,
String locale = 'zh',
}) async {
return _notificationApi.markRead(
notificationId: notificationId,
locale: locale,
);
} }
@override @override
@@ -15,6 +15,7 @@ class NotificationState {
this.unreadCount = 0, this.unreadCount = 0,
this.hasMore = false, this.hasMore = false,
this.nextCursor, this.nextCursor,
this.isLoadingMore = false,
this.errorMessage, this.errorMessage,
}); });
@@ -23,6 +24,7 @@ class NotificationState {
final int unreadCount; final int unreadCount;
final bool hasMore; final bool hasMore;
final String? nextCursor; final String? nextCursor;
final bool isLoadingMore;
final String? errorMessage; final String? errorMessage;
NotificationState copyWith({ NotificationState copyWith({
@@ -31,6 +33,7 @@ class NotificationState {
int? unreadCount, int? unreadCount,
bool? hasMore, bool? hasMore,
String? nextCursor, String? nextCursor,
bool? isLoadingMore,
String? errorMessage, String? errorMessage,
}) { }) {
return NotificationState( return NotificationState(
@@ -39,6 +42,7 @@ class NotificationState {
unreadCount: unreadCount ?? this.unreadCount, unreadCount: unreadCount ?? this.unreadCount,
hasMore: hasMore ?? this.hasMore, hasMore: hasMore ?? this.hasMore,
nextCursor: nextCursor ?? this.nextCursor, nextCursor: nextCursor ?? this.nextCursor,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
); );
} }
@@ -81,10 +85,13 @@ final class NotificationRevokedEvent extends NotificationEvent {
} }
class NotificationBloc extends ChangeNotifier { class NotificationBloc extends ChangeNotifier {
NotificationBloc({required NotificationRepository repository}) NotificationBloc({
: _repository = repository; required NotificationRepository repository,
this.locale = 'zh',
}) : _repository = repository;
final NotificationRepository _repository; final NotificationRepository _repository;
final String locale;
final Logger _logger = getLogger('features.notifications.bloc'); final Logger _logger = getLogger('features.notifications.bloc');
NotificationState _state = const NotificationState(); NotificationState _state = const NotificationState();
@@ -119,7 +126,10 @@ class NotificationBloc extends ChangeNotifier {
notifyListeners(); notifyListeners();
try { try {
final result = await _repository.listNotifications(limit: 20); final result = await _repository.listNotifications(
limit: 20,
locale: locale,
);
_state = _state.copyWith( _state = _state.copyWith(
status: NotificationStatus.loaded, status: NotificationStatus.loaded,
items: result.items, items: result.items,
@@ -143,7 +153,10 @@ class NotificationBloc extends ChangeNotifier {
Future<void> _refreshNotifications() async { Future<void> _refreshNotifications() async {
try { try {
final result = await _repository.listNotifications(limit: 20); final result = await _repository.listNotifications(
limit: 20,
locale: locale,
);
_state = _state.copyWith( _state = _state.copyWith(
status: NotificationStatus.loaded, status: NotificationStatus.loaded,
items: result.items, items: result.items,
@@ -161,18 +174,25 @@ class NotificationBloc extends ChangeNotifier {
} }
Future<void> _loadMore() async { Future<void> _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 { try {
final result = await _repository.listNotifications( final result = await _repository.listNotifications(
limit: 20, limit: 20,
cursor: _state.nextCursor, cursor: _state.nextCursor,
locale: locale,
); );
final allItems = [..._state.items, ...result.items]; final allItems = [..._state.items, ...result.items];
_state = _state.copyWith( _state = _state.copyWith(
items: allItems, items: allItems,
hasMore: result.hasMore, hasMore: result.hasMore,
nextCursor: result.nextCursor, nextCursor: result.nextCursor,
isLoadingMore: false,
); );
notifyListeners(); notifyListeners();
} catch (error, stackTrace) { } catch (error, stackTrace) {
@@ -181,6 +201,8 @@ class NotificationBloc extends ChangeNotifier {
error: error, error: error,
stackTrace: stackTrace, stackTrace: stackTrace,
); );
_state = _state.copyWith(isLoadingMore: false);
notifyListeners();
} }
} }
@@ -197,6 +219,7 @@ class NotificationBloc extends ChangeNotifier {
try { try {
final updated = await _repository.markRead( final updated = await _repository.markRead(
notificationId: notificationId, notificationId: notificationId,
locale: locale,
); );
final targetIndex = _state.items.indexWhere( final targetIndex = _state.items.indexWhere(
(item) => item.id == updated.id, (item) => item.id == updated.id,
@@ -2,7 +2,9 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/notification/notification_detail_bottom_sheet.dart'; import '../../../../shared/widgets/notification/notification_detail_bottom_sheet.dart';
import '../../data/models/notification_item.dart'; import '../../data/models/notification_item.dart';
import '../../data/models/notification_payload.dart'; import '../../data/models/notification_payload.dart';
@@ -31,54 +33,93 @@ class NotificationCenterScreen extends StatefulWidget {
} }
class _NotificationCenterScreenState extends State<NotificationCenterScreen> { class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
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 @override
void initState() { void initState() {
super.initState(); super.initState();
_bloc = NotificationBloc(repository: widget.repository); _scrollController = ScrollController()..addListener(_onScroll);
_bloc.handleEvent(LoadNotifications()); }
_bloc.addListener(_onStateChanged);
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_bloc == null) {
_bloc = NotificationBloc(
repository: widget.repository,
locale: _currentLocale,
);
_bloc!.handleEvent(LoadNotifications());
_bloc!.addListener(_onStateChanged);
}
} }
void _onStateChanged() { void _onStateChanged() {
setState(() {}); setState(() {});
} }
void _onScroll() {
if (!_scrollController.hasClients || _bloc == null) return;
final position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 240) {
unawaited(_bloc!.handleEvent(LoadMoreNotifications()));
}
}
@override @override
void dispose() { void dispose() {
_bloc.removeListener(_onStateChanged); _bloc?.removeListener(_onStateChanged);
_bloc.dispose(); _bloc?.dispose();
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
final state = _bloc.state; final l10n = AppLocalizations.of(context)!;
final state = _bloc!.state;
return Scaffold( return Scaffold(
backgroundColor: colors.surfaceContainerLow,
appBar: AppBar( appBar: AppBar(
title: const Text('通知'), title: Text(l10n.notifyCenterTitle),
centerTitle: true, centerTitle: true,
backgroundColor: colors.surfaceContainerLow,
surfaceTintColor: colors.surfaceContainerLow,
actions: [ actions: [
if (state.items.any((item) => !item.isRead)) if (state.items.any((item) => !item.isRead))
TextButton( TextButton(
onPressed: _onMarkAllRead, onPressed: _onMarkAllRead,
child: Text('全部已读', style: TextStyle(color: colors.primary)), child: Text(
l10n.notifyMarkAllRead,
style: TextStyle(color: colors.primary),
),
), ),
], ],
), ),
body: RefreshIndicator( body: RefreshIndicator(
onRefresh: () => _bloc.handleEvent(RefreshNotifications()), onRefresh: () => _bloc!.handleEvent(RefreshNotifications()),
child: _buildBody(state, colors), 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) { 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) { if (state.status == NotificationStatus.error && state.items.isEmpty) {
@@ -88,11 +129,14 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
children: [ children: [
Icon(Icons.error_outline, size: 48, color: colors.error), Icon(Icons.error_outline, size: 48, color: colors.error),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
Text('加载失败', style: TextStyle(color: colors.onSurfaceVariant)), Text(
l10n.notifyLoadFailed,
style: TextStyle(color: colors.onSurfaceVariant),
),
const SizedBox(height: AppSpacing.sm), const SizedBox(height: AppSpacing.sm),
FilledButton( FilledButton(
onPressed: () => _bloc.handleEvent(LoadNotifications()), onPressed: () => _bloc!.handleEvent(LoadNotifications()),
child: const Text('重试'), child: Text(l10n.notifyRetry),
), ),
], ],
), ),
@@ -115,7 +159,7 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
), ),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
Text( Text(
'暂无通知', l10n.notifyEmpty,
style: TextStyle( style: TextStyle(
color: colors.onSurfaceVariant, color: colors.onSurfaceVariant,
fontSize: 16, fontSize: 16,
@@ -130,13 +174,13 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
} }
return ListView.builder( return ListView.builder(
controller: _scrollController,
itemCount: state.items.length + (state.hasMore ? 1 : 0), itemCount: state.items.length + (state.hasMore ? 1 : 0),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == state.items.length && state.hasMore) { if (index == state.items.length && state.hasMore) {
_bloc.handleEvent(LoadMoreNotifications());
return const Padding( return const Padding(
padding: EdgeInsets.all(AppSpacing.lg), padding: EdgeInsets.all(AppSpacing.lg),
child: Center(child: CircularProgressIndicator()), child: Center(child: AppLoadingIndicator()),
); );
} }
final item = state.items[index]; final item = state.items[index];
@@ -154,11 +198,13 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
) async { ) async {
final wasUnread = !item.isRead; final wasUnread = !item.isRead;
if (!item.isRead) { if (!item.isRead) {
await _bloc.handleEvent(MarkNotificationRead(notificationId: item.id)); await _bloc!.handleEvent(MarkNotificationRead(notificationId: item.id));
final updatedIndex = _bloc.state.items.indexWhere((n) => n.id == item.id); final updatedIndex = _bloc!.state.items.indexWhere(
(n) => n.id == item.id,
);
if (wasUnread && if (wasUnread &&
updatedIndex >= 0 && updatedIndex >= 0 &&
_bloc.state.items[updatedIndex].isRead) { _bloc!.state.items[updatedIndex].isRead) {
await widget.onUnreadCountChanged?.call(); await widget.onUnreadCountChanged?.call();
} }
} }
@@ -188,9 +234,9 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
} }
Future<void> _markAllRead() async { Future<void> _markAllRead() async {
final unreadBefore = _bloc.state.items.any((item) => !item.isRead); final unreadBefore = _bloc!.state.items.any((item) => !item.isRead);
await _bloc.handleEvent(MarkAllNotificationsRead()); await _bloc!.handleEvent(MarkAllNotificationsRead());
final unreadAfter = _bloc.state.items.any((item) => !item.isRead); final unreadAfter = _bloc!.state.items.any((item) => !item.isRead);
if (unreadBefore && !unreadAfter) { if (unreadBefore && !unreadAfter) {
await widget.onUnreadCountChanged?.call(); await widget.onUnreadCountChanged?.call();
} }
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/utils/time_format.dart';
import '../../data/models/notification_item.dart'; import '../../data/models/notification_item.dart';
class NotificationListItem extends StatelessWidget { class NotificationListItem extends StatelessWidget {
@@ -18,90 +19,76 @@ class NotificationListItem extends StatelessWidget {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return IntrinsicHeight( return InkWell(
child: InkWell( onTap: onTap,
onTap: onTap, child: Container(
child: Container( padding: const EdgeInsets.symmetric(
padding: const EdgeInsets.symmetric( horizontal: AppSpacing.lg,
horizontal: AppSpacing.lg, vertical: AppSpacing.md,
vertical: AppSpacing.md, ),
), decoration: BoxDecoration(
decoration: BoxDecoration( color: item.isRead ? colors.surface : colors.surfaceContainerHighest,
color: item.isRead border: Border(
? colors.surface bottom: BorderSide(
: colors.surfaceContainerHighest, color: colors.outlineVariant.withValues(alpha: 0.3),
border: Border( width: 0.5,
bottom: BorderSide(
color: colors.outlineVariant.withValues(alpha: 0.3),
width: 0.5,
),
), ),
), ),
child: Row( ),
crossAxisAlignment: CrossAxisAlignment.start, child: Row(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
if (!item.isRead) children: [
Container( if (!item.isRead)
margin: const EdgeInsets.only( Container(
top: AppSpacing.sm, margin: const EdgeInsets.only(
right: AppSpacing.sm, top: AppSpacing.sm,
), right: AppSpacing.sm,
width: 8,
height: 8,
decoration: BoxDecoration(
color: colors.primary,
shape: BoxShape.circle,
),
), ),
Expanded( width: 8,
child: Column( height: 8,
crossAxisAlignment: CrossAxisAlignment.start, decoration: BoxDecoration(
mainAxisSize: MainAxisSize.min, color: colors.primary,
children: [ shape: BoxShape.circle,
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,
),
),
],
), ),
), ),
], 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}';
}
} }
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../../features/notifications/data/models/notification_item.dart'; import '../../../features/notifications/data/models/notification_item.dart';
import '../../theme/design_tokens.dart'; import '../../theme/design_tokens.dart';
import '../../utils/time_format.dart';
class NotificationDetailBottomSheet extends StatefulWidget { class NotificationDetailBottomSheet extends StatefulWidget {
const NotificationDetailBottomSheet({ const NotificationDetailBottomSheet({
@@ -76,7 +77,7 @@ class _NotificationDetailBottomSheetState
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
child: Text( child: Text(
_formatTime(widget.item.createdAt), formatRelativeTime(context, widget.item.createdAt),
style: textTheme.labelSmall?.copyWith(color: colors.outline), 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<void> showNotificationDetailBottomSheet({ Future<void> showNotificationDetailBottomSheet({
@@ -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', '');
"""
)
@@ -7,7 +7,14 @@ from typing import Literal
from uuid import UUID from uuid import UUID
import yaml 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 ( from backend.src.schemas.shared.notification import (
NotificationPayload, NotificationPayload,
@@ -18,6 +25,7 @@ from schemas.enums import NotificationTargetMode
class StaticNotificationDefinition(BaseModel): class StaticNotificationDefinition(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") 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) source_key: str = Field(min_length=1, max_length=128)
version: int = Field(ge=1) version: int = Field(ge=1)
@@ -25,10 +33,22 @@ class StaticNotificationDefinition(BaseModel):
status: Literal["draft", "published", "revoked"] status: Literal["draft", "published", "revoked"]
deleted: bool = False deleted: bool = False
published_at: datetime | None = None published_at: datetime | None = None
title: str = Field(min_length=1) title: dict[str, str] = Field(min_length=1)
body: str = Field(min_length=1) body: dict[str, str] = Field(min_length=1)
payload: NotificationPayload = NotificationPayloadNone(action="none") 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): class StaticNotificationTargets(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@@ -1,10 +1,16 @@
notification: notification:
source_key: welcome_points source_key: welcome_points
version: 1 version: 2
type: system type: system
status: published status: published
title: 欢迎来到觅爻 title:
body: 你已获得新用户奖励,点击前往积分页查看当前余额。 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: payload:
action: open_route action: open_route
route: /points route: /points
+9 -5
View File
@@ -3,12 +3,12 @@ from __future__ import annotations
import uuid import uuid
from datetime import datetime 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.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, SoftDeleteMixin, TimestampMixin 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 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_key: Mapped[str | None] = mapped_column(String(128), nullable=True)
source_version: Mapped[int | None] = mapped_column(nullable=True) source_version: Mapped[int | None] = mapped_column(nullable=True)
content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
title: Mapped[str] = mapped_column(Text, nullable=False) title: Mapped[dict[str, str]] = mapped_column(
body: Mapped[str] = mapped_column(Text, nullable=False) 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( payload: Mapped[dict[str, object]] = mapped_column(
json_jsonb, jsonb,
nullable=False, nullable=False,
server_default=text("'{}'::jsonb"), server_default=text("'{}'::jsonb"),
default=dict, default=dict,
+24 -13
View File
@@ -1,11 +1,13 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from core.logging import get_logger from core.logging import get_logger
from core.auth.models import CurrentUser 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.dependencies import get_notification_service
from v1.notifications.schemas import ( from v1.notifications.schemas import (
MarkAllReadResponse, MarkAllReadResponse,
@@ -13,33 +15,42 @@ from v1.notifications.schemas import (
NotificationListResponse, NotificationListResponse,
UnreadCountResponse, UnreadCountResponse,
) )
from v1.notifications.service import NotificationService from v1.notifications.service import NotificationService, normalize_locale
from v1.users.dependencies import get_current_user from v1.users.dependencies import get_current_user
router = APIRouter(prefix="/notifications", tags=["notifications"]) router = APIRouter(prefix="/notifications", tags=["notifications"])
logger = get_logger("v1.notifications.router") 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) @router.get("", response_model=NotificationListResponse)
async def list_notifications( async def list_notifications(
service: Annotated[NotificationService, Depends(get_notification_service)], service: Annotated[NotificationService, Depends(get_notification_service)],
current_user: Annotated[CurrentUser, Depends(get_current_user)], current_user: Annotated[CurrentUser, Depends(get_current_user)],
limit: int = Query(default=20, ge=1, le=50), limit: int = Query(default=20, ge=1, le=50),
cursor: str | None = Query(default=None), cursor: str | None = Query(default=None),
locale: str | None = Query(default=None),
) -> NotificationListResponse: ) -> 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( result = await service.list_notifications(
user_id=current_user.id, user_id=current_user.id,
limit=limit, limit=limit,
cursor=parsed_cursor, cursor=_parse_cursor(cursor),
locale=normalize_locale(locale),
) )
logger.info( logger.info(
"Notification list fetched", "Notification list fetched",
@@ -89,14 +100,13 @@ async def mark_notification_read(
notification_id: str, notification_id: str,
service: Annotated[NotificationService, Depends(get_notification_service)], service: Annotated[NotificationService, Depends(get_notification_service)],
current_user: Annotated[CurrentUser, Depends(get_current_user)], current_user: Annotated[CurrentUser, Depends(get_current_user)],
locale: str | None = Query(default=None),
) -> NotificationItemResponse: ) -> NotificationItemResponse:
from uuid import UUID from uuid import UUID
try: try:
uid = UUID(notification_id) uid = UUID(notification_id)
except ValueError: except ValueError:
from core.http.errors import ApiProblemError, problem_payload
raise ApiProblemError( raise ApiProblemError(
status_code=404, status_code=404,
detail=problem_payload( detail=problem_payload(
@@ -108,6 +118,7 @@ async def mark_notification_read(
item = await service.mark_read( item = await service.mark_read(
user_notification_id=uid, user_notification_id=uid,
user_id=current_user.id, user_id=current_user.id,
locale=normalize_locale(locale),
) )
logger.info( logger.info(
"Notification marked as read", "Notification marked as read",
+39 -5
View File
@@ -13,6 +13,35 @@ from v1.notifications.schemas import (
NotificationPayload, 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) @dataclass(frozen=True)
class NotificationListItem: class NotificationListItem:
@@ -44,6 +73,7 @@ class NotificationService:
user_id: UUID, user_id: UUID,
limit: int = 20, limit: int = 20,
cursor: datetime | None = None, cursor: datetime | None = None,
locale: str = DEFAULT_LOCALE,
) -> NotificationListResult: ) -> NotificationListResult:
actual_limit = min(limit, 50) actual_limit = min(limit, 50)
rows = await self._repository.list_notifications( rows = await self._repository.list_notifications(
@@ -65,8 +95,8 @@ class NotificationService:
id=un.id, id=un.id,
notification_id=n.id, notification_id=n.id,
type=n.type, type=n.type,
title=n.title, title=resolve_i18n_text(n.title, locale),
body=n.body, body=resolve_i18n_text(n.body, locale),
payload=payload, payload=payload,
is_read=un.is_read, is_read=un.is_read,
read_at=un.read_at, read_at=un.read_at,
@@ -83,7 +113,11 @@ class NotificationService:
return await self._repository.get_unread_count(user_id=user_id) return await self._repository.get_unread_count(user_id=user_id)
async def mark_read( 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: ) -> NotificationListItem:
result = await self._repository.get_user_notification( result = await self._repository.get_user_notification(
user_notification_id=user_notification_id, user_notification_id=user_notification_id,
@@ -109,8 +143,8 @@ class NotificationService:
id=un.id, id=un.id,
notification_id=n.id, notification_id=n.id,
type=n.type, type=n.type,
title=n.title, title=resolve_i18n_text(n.title, locale),
body=n.body, body=resolve_i18n_text(n.body, locale),
payload=payload, payload=payload,
is_read=True, is_read=True,
read_at=un.read_at or datetime.now(timezone.utc), read_at=un.read_at or datetime.now(timezone.utc),
+93 -10
View File
@@ -5,7 +5,12 @@ from uuid import UUID, uuid4
import pytest 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 ( from v1.notifications.schemas import (
NotificationPayloadNone, NotificationPayloadNone,
NotificationPayloadRoute, NotificationPayloadRoute,
@@ -39,8 +44,8 @@ class _FakeNotification:
*, *,
id: UUID, id: UUID,
type: str = "system", type: str = "system",
title: str = "Test", title: dict[str, str] | None = None,
body: str = "Test body", body: dict[str, str] | None = None,
payload: dict | None = None, payload: dict | None = None,
status: str = "published", status: str = "published",
deleted_at: datetime | None = None, deleted_at: datetime | None = None,
@@ -48,8 +53,8 @@ class _FakeNotification:
): ):
self.id = id self.id = id
self.type = type self.type = type
self.title = title self.title = title or {"zh": "Test"}
self.body = body self.body = body or {"zh": "Test body"}
self.payload = payload or {"action": "none"} self.payload = payload or {"action": "none"}
self.status = status self.status = status
self.deleted_at = deleted_at self.deleted_at = deleted_at
@@ -154,8 +159,8 @@ def _make_notification(
notification_id: UUID | None = None, notification_id: UUID | None = None,
is_read: bool = False, is_read: bool = False,
read_at: datetime | None = None, read_at: datetime | None = None,
title: str = "Test", title: dict[str, str] | None = None,
body: str = "Test body", body: dict[str, str] | None = None,
payload: dict | None = None, payload: dict | None = None,
status: str = "published", status: str = "published",
deleted_at: datetime | None = None, deleted_at: datetime | None = None,
@@ -185,8 +190,12 @@ class TestListNotifications:
async def test_returns_only_user_a_notifications( async def test_returns_only_user_a_notifications(
self, service: NotificationService, fake_repo: _FakeNotificationRepository self, service: NotificationService, fake_repo: _FakeNotificationRepository
): ):
un_a, n_a = _make_notification(user_id=USER_A, title="A1") un_a, n_a = _make_notification(
un_b, n_b = _make_notification(user_id=USER_B, title="B1") 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_a, n_a)
fake_repo.add_item(un_b, n_b) fake_repo.add_item(un_b, n_b)
@@ -219,7 +228,9 @@ class TestListNotifications:
self, service: NotificationService, fake_repo: _FakeNotificationRepository self, service: NotificationService, fake_repo: _FakeNotificationRepository
): ):
for i in range(3): 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) fake_repo.add_item(un, n)
result = await service.list_notifications(user_id=USER_A, limit=2) result = await service.list_notifications(user_id=USER_A, limit=2)
@@ -383,3 +394,75 @@ class TestParsePayload:
assert payload.route == "/settings" assert payload.route == "/settings"
assert payload.entity_id is None assert payload.entity_id is None
assert payload.tab 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 == "正文"
@@ -27,8 +27,12 @@ def test_load_static_notification_file_parses_valid_yaml(tmp_path: Path) -> None
version: 1 version: 1
type: system type: system
status: published status: published
title: Welcome title:
body: Welcome to the app. zh: 欢迎
en: Welcome
body:
zh: 欢迎使用
en: Welcome to the app.
payload: payload:
action: open_route action: open_route
route: /points 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) loaded = load_static_notification_file(file_path)
assert loaded.notification.source_key == "welcome_bonus" 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.notification.payload.action == "open_route"
assert loaded.targets.mode == NotificationTargetMode.USER_IDS assert loaded.targets.mode == NotificationTargetMode.USER_IDS
assert len(loaded.targets.user_ids or []) == 1 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 version: 1
type: system type: system
status: published status: published
title: Welcome title:
body: You got points. zh: 欢迎
body:
zh: 你好
payload: payload:
action: open_route action: open_route
route: /points route: /points
@@ -85,8 +93,10 @@ def test_load_static_notification_file_parses_exist_users(tmp_path: Path) -> Non
version: 1 version: 1
type: system type: system
status: published status: published
title: Come back title:
body: We miss you. zh: 回来吧
body:
zh: 想你
payload: payload:
action: none action: none
targets: targets:
@@ -110,8 +120,10 @@ def test_load_static_notification_file_parses_all_users(tmp_path: Path) -> None:
version: 1 version: 1
type: system type: system
status: published status: published
title: Announcement title:
body: Maintenance at midnight. zh: 公告
body:
zh: 午夜维护
payload: payload:
action: none action: none
targets: targets:
@@ -134,8 +146,10 @@ def test_load_static_notification_file_rejects_invalid_targets(tmp_path: Path) -
version: 1 version: 1
type: system type: system
status: published status: published
title: Invalid title:
body: Invalid targets. zh: 无效
body:
zh: 无效
payload: payload:
action: none action: none
targets: targets:
@@ -149,6 +163,88 @@ def test_load_static_notification_file_rejects_invalid_targets(tmp_path: Path) -
load_static_notification_file(file_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: def test_load_static_notification_file_rejects_unknown_mode(tmp_path: Path) -> None:
file_path = tmp_path / "bad_mode.yaml" file_path = tmp_path / "bad_mode.yaml"
_write_yaml( _write_yaml(
@@ -159,8 +255,10 @@ def test_load_static_notification_file_rejects_unknown_mode(tmp_path: Path) -> N
version: 1 version: 1
type: system type: system
status: published status: published
title: Bad title:
body: Bad mode. zh: 坏
body:
zh: 坏
payload: payload:
action: none action: none
targets: targets:
@@ -184,8 +282,10 @@ def test_load_static_notification_file_rejects_new_users_with_user_ids(
version: 1 version: 1
type: system type: system
status: published status: published
title: Bad title:
body: Bad. zh: 坏
body:
zh: 坏
payload: payload:
action: none action: none
targets: targets:
@@ -211,8 +311,10 @@ def test_load_static_notification_file_rejects_user_ids_without_list(
version: 1 version: 1
type: system type: system
status: published status: published
title: Bad title:
body: Bad. zh: 坏
body:
zh: 坏
payload: payload:
action: none action: none
targets: targets:
@@ -235,8 +337,10 @@ def test_load_static_notification_documents_rejects_duplicate_source_key(
version: 1 version: 1
type: system type: system
status: published status: published
title: First title:
body: First body. zh: 第一
body:
zh: 第一
payload: payload:
action: none action: none
targets: targets:
@@ -251,8 +355,10 @@ def test_load_static_notification_documents_rejects_duplicate_source_key(
version: 1 version: 1
type: system type: system
status: published status: published
title: Second title:
body: Second body. zh: 第二
body:
zh: 第二
payload: payload:
action: none action: none
targets: targets:
@@ -275,8 +381,10 @@ def test_content_hash_changes_when_notification_changes(tmp_path: Path) -> None:
version: 1 version: 1
type: system type: system
status: published status: published
title: Title A title:
body: Body A. zh: 标题A
body:
zh: 正文A
payload: payload:
action: none action: none
targets: targets:
@@ -291,8 +399,10 @@ def test_content_hash_changes_when_notification_changes(tmp_path: Path) -> None:
version: 1 version: 1
type: system type: system
status: published status: published
title: Title B title:
body: Body A. zh: 标题B
body:
zh: 正文A
payload: payload:
action: none action: none
targets: targets:
@@ -319,8 +429,10 @@ def test_load_static_notification_file_supports_deleted_flag(tmp_path: Path) ->
type: system type: system
status: revoked status: revoked
deleted: true deleted: true
title: Deleted title:
body: Deleted body. zh: 已删
body:
zh: 已删
payload: payload:
action: none action: none
targets: targets:
@@ -27,6 +27,7 @@ List notifications for the current user.
- `limit` (optional, integer, default 20, max 50): number of items per page - `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`) - `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)**: **Response (200)**:
@@ -61,6 +62,7 @@ Field rules:
- `payload`: discriminated union (see Payload section below) - `payload`: discriminated union (see Payload section below)
- `isRead`: boolean - `isRead`: boolean
- `readAt`: ISO 8601 timestamp or `null` - `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` - Results are filtered: `notifications.status = 'published'` and `notifications.deleted_at IS NULL`
### GET /api/v1/notifications/unread-count ### 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 - `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)**: **Response (200)**:
```json ```json
@@ -41,8 +41,14 @@ notification:
type: system type: system
status: published status: published
published_at: 2026-04-10T08:00:00Z published_at: 2026-04-10T08:00:00Z
title: 新用户欢迎通知 title:
body: 你已获得注册奖励,可前往积分中心查看。 zh: 新用户欢迎通知
zh_Hant: 新用戶歡迎通知
en: Welcome
body:
zh: 你已获得注册奖励,可前往积分中心查看。
zh_Hant: 你已獲得註冊獎勵,可前往積分中心查看。
en: You have received a registration reward. Check your points.
payload: payload:
action: open_route action: open_route
route: /points route: /points
@@ -60,8 +66,8 @@ targets:
- `status`: required, one of `draft`, `published`, `revoked` - `status`: required, one of `draft`, `published`, `revoked`
- `deleted`: optional, boolean, default `false`, soft-delete this notification - `deleted`: optional, boolean, default `false`, soft-delete this notification
- `published_at`: optional ISO 8601 timestamp - `published_at`: optional ISO 8601 timestamp
- `title`: 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 string - `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 - `payload`: required, must follow the notification payload protocol
### targets ### targets