chore(deploy): add backend ECR deployment flow #1

Merged
qzl merged 24 commits from dev into main 2026-04-29 18:07:11 +08:00
16 changed files with 601 additions and 213 deletions
Showing only changes of commit a940f2ea47 - Show all commits
@@ -15,15 +15,19 @@ class NotificationApi {
Future<NotificationListResult> listNotifications({
int limit = 20,
String? cursor,
String locale = 'zh',
}) async {
final queryParts = <String>['limit=$limit'];
final queryParameters = <String, Object>{'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<Map<String, dynamic>>(
'/api/v1/notifications',
queryParameters: queryParameters,
);
final json = response.data ?? <String, dynamic>{};
final itemsJson = json['items'] as List<dynamic>? ?? [];
final items = itemsJson
.map((e) => parseNotificationItem(e as Map<String, dynamic>))
@@ -59,21 +63,16 @@ class NotificationApi {
}
}
Future<NotificationItem> markRead({required String notificationId}) async {
_logger.info(
message: 'Mark read request started',
extra: {'notification_id': notificationId},
);
Future<NotificationItem> markRead({
required String notificationId,
String locale = 'zh',
}) async {
try {
final response = await _apiClient.rawDio.patch<Map<String, dynamic>>(
'/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<int> markAllRead() async {
_logger.info(message: 'Mark all read request started');
try {
final response = await _apiClient.rawDio.patch<Map<String, dynamic>>(
'/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',
@@ -6,11 +6,15 @@ abstract class NotificationRepository {
Future<NotificationListResult> listNotifications({
int limit = 20,
String? cursor,
String locale = 'zh',
});
Future<int> getUnreadCount();
Future<NotificationItem> markRead({required String notificationId});
Future<NotificationItem> markRead({
required String notificationId,
String locale = 'zh',
});
Future<int> markAllRead();
}
@@ -25,8 +29,13 @@ class NotificationRepositoryImpl implements NotificationRepository {
Future<NotificationListResult> 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<NotificationItem> markRead({required String notificationId}) async {
return _notificationApi.markRead(notificationId: notificationId);
Future<NotificationItem> markRead({
required String notificationId,
String locale = 'zh',
}) async {
return _notificationApi.markRead(
notificationId: notificationId,
locale: locale,
);
}
@override
@@ -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<void> _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<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 {
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,
@@ -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<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
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<NotificationCenterScreen> {
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<NotificationCenterScreen> {
),
const SizedBox(height: AppSpacing.md),
Text(
'暂无通知',
l10n.notifyEmpty,
style: TextStyle(
color: colors.onSurfaceVariant,
fontSize: 16,
@@ -130,13 +174,13 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
}
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<NotificationCenterScreen> {
) 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<NotificationCenterScreen> {
}
Future<void> _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();
}
@@ -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,8 +19,7 @@ class NotificationListItem extends StatelessWidget {
final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return IntrinsicHeight(
child: InkWell(
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(
@@ -27,9 +27,7 @@ class NotificationListItem extends StatelessWidget {
vertical: AppSpacing.md,
),
decoration: BoxDecoration(
color: item.isRead
? colors.surface
: colors.surfaceContainerHighest,
color: item.isRead ? colors.surface : colors.surfaceContainerHighest,
border: Border(
bottom: BorderSide(
color: colors.outlineVariant.withValues(alpha: 0.3),
@@ -80,7 +78,7 @@ class NotificationListItem extends StatelessWidget {
),
const SizedBox(height: AppSpacing.xs),
Text(
_formatTime(item.createdAt),
formatRelativeTime(context, item.createdAt),
style: textTheme.labelSmall?.copyWith(
color: colors.outline,
),
@@ -91,17 +89,6 @@ class NotificationListItem extends StatelessWidget {
],
),
),
),
);
}
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 '../../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<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
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")
@@ -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
+9 -5
View File
@@ -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,
+24 -13
View File
@@ -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",
+39 -5
View File
@@ -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),
+93 -10
View File
@@ -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 == "正文"
@@ -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:
@@ -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
@@ -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