diff --git a/AGENTS.md b/AGENTS.md index eb9ed55..616a63f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,39 +44,8 @@ When viewing data in the database, use `supabase mcp` tools (`supabase_execute_s ## Image Handling -When reading images, use `understand_image` tool instead of `Read` tool, especially when the model supports multimodal capabilities. Only use `Read` tool for non-image files. +When reading images, check whether the model has native multimodal capability first. If it does, use `Read` tool to read images directly. If it does not, fall back to `understand_image` tool. Only use `Read` tool for non-image files. -## Mobile Automation - -Use Midscene Skills for mobile UI automation. - -### When to trigger -If the user asks to open app, navigate pages, tap, input text, scroll, verify UI, reproduce bug, or run mobile tests → treat as executable automation, not just explanation. - -### Platform -- iOS → use Midscene iOS (requires WebDriverAgent at http://localhost:8100/status) -- Android → use Midscene Android (requires `adb devices` available) - -If platform not specified: -- Use current project platform if obvious -- Otherwise ask - -### Preconditions -- iOS: WDA must be ready -- Android: device/emulator must be connected - -If not ready → stop and report missing requirement - -### Execution -- Perform actual UI actions via Midscene Skills -- Do not only describe test plan -- Capture result (screen state / success / failure step) - -### Output -Return: -- success or failure -- first failing step (if any) -- key observation # Trellis Instructions diff --git a/apps/AGENTS.md b/apps/AGENTS.md index fb1b795..a4f5269 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -45,6 +45,26 @@ This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable. - `AppTheme.light` / `AppTheme.dark` provide complete `ColorScheme` (light + dark). `MaterialApp` wires them via `theme:` / `darkTheme:`. - If a semantic slot is missing from `ColorScheme`, add it to `AppTheme` — do not bypass `colorScheme` with hardcoded values. +### Page Header (Must) + +All sub-pages (sub-page = any page that is not a home Tab page) `AppBar` must follow: + +- **`centerTitle: true`** — title must be horizontally centered; never left-aligned. +- **`backgroundColor`** and **`surfaceTintColor`** should match the page background to avoid visual seams. +- Example: + +```dart +appBar: AppBar( + title: Text('Notifications'), + centerTitle: true, + backgroundColor: colors.surfaceContainerLow, + surfaceTintColor: colors.surfaceContainerLow, + actions: [...], +), +``` + +- When a repeated pattern emerges, extract a reusable component into `shared/widgets/` instead of building `AppBar` independently in each page. + ## Divination Terminology (Must) - Divination domain terminology must use fixed Chinese terms in code contracts, protocol fields, and UI semantic labels. diff --git a/apps/lib/app/app.dart b/apps/lib/app/app.dart index f7c4128..e824453 100644 --- a/apps/lib/app/app.dart +++ b/apps/lib/app/app.dart @@ -53,6 +53,7 @@ class _EryaoAppState extends State { List _historyRecords = const []; bool _loadingProfile = false; String? _loadedProfileUserEmail; + String? _lastUnreadRefreshedUserId; @override void initState() { @@ -77,9 +78,23 @@ class _EryaoAppState extends State { sessionStore: _sessionStore, ); _authBloc = AuthBloc(repository: authRepository); + _authBloc.addListener(_onAuthStateChanged); _bootstrap(); } + void _onAuthStateChanged() { + final state = _authBloc.state; + if (state.status == AuthStatus.authenticated && state.user != null) { + final userId = state.user!.id; + if (_lastUnreadRefreshedUserId != userId) { + _lastUnreadRefreshedUserId = userId; + _notificationBloc.handleEvent(RefreshUnreadCount()); + } + return; + } + _lastUnreadRefreshedUserId = null; + } + void _ensureCreditsLoaded(String userEmail) { if (_loadingCredits) { return; @@ -357,6 +372,7 @@ class _EryaoAppState extends State { @override void dispose() { + _authBloc.removeListener(_onAuthStateChanged); _authBloc.dispose(); _notificationBloc.dispose(); super.dispose(); @@ -427,7 +443,6 @@ class _EryaoAppState extends State { _ensureCreditsLoaded(state.user!.email); _ensureHistoryLoaded(state.user!.email); _refreshProfile(userEmail: state.user!.email); - _notificationBloc.handleEvent(RefreshUnreadCount()); return HomeScreen( account: state.user!.email, sessionStore: _sessionStore, diff --git a/apps/lib/features/divination/data/models/yao_coin_converter.dart b/apps/lib/features/divination/data/models/yao_coin_converter.dart new file mode 100644 index 0000000..ae28348 --- /dev/null +++ b/apps/lib/features/divination/data/models/yao_coin_converter.dart @@ -0,0 +1,19 @@ +import 'divination_params.dart'; + +class YaoCoinConverter { + const YaoCoinConverter._(); + + static YaoType fromHuaCount(int huaCount) { + return switch (huaCount) { + 0 => YaoType.oldYin, + 1 => YaoType.youngYang, + 2 => YaoType.youngYin, + 3 => YaoType.oldYang, + _ => throw ArgumentError.value(huaCount, 'huaCount', 'must be 0..3'), + }; + } + + static YaoType fromZiCount(int ziCount) { + return fromHuaCount(3 - ziCount); + } +} diff --git a/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart b/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart index 1987e5c..6405b90 100644 --- a/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart +++ b/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart @@ -14,7 +14,6 @@ import '../../../../shared/widgets/app_modal_dialog.dart'; import '../../../../shared/widgets/gua_icon.dart'; import '../../../../shared/widgets/divination/divination_shared_widgets.dart'; import '../../../../shared/widgets/divination/divination_terms.dart'; -import '../../../../shared/widgets/divination/yao_legend.dart'; import '../../../../shared/widgets/divination/yao_line_row.dart'; import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart'; import '../../../../shared/widgets/toast/toast.dart'; @@ -23,6 +22,7 @@ import '../../data/models/divination_backend_models.dart'; import '../../data/apis/divination_api.dart'; import '../../data/models/divination_params.dart'; import '../../data/models/divination_result.dart'; +import '../../data/models/yao_coin_converter.dart'; import '../../data/services/divination_run_service.dart'; import 'divination_processing_screen.dart'; @@ -287,14 +287,8 @@ class _AutoDivinationScreenState extends State final c1 = _random.nextBool(); final c2 = _random.nextBool(); final c3 = _random.nextBool(); - final yangCount = [c1, c2, c3].where((v) => v).length; - final yao = switch (yangCount) { - 0 => YaoType.oldYin, - 1 => YaoType.youngYang, - 2 => YaoType.youngYin, - 3 => YaoType.oldYang, - _ => YaoType.undetermined, - }; + final ziCount = [c1, c2, c3].where((v) => v).length; + final yao = YaoCoinConverter.fromZiCount(ziCount); setState(() { _isSpinning = false; _coin1Yang = c1; @@ -737,7 +731,6 @@ class _HexagramCard extends StatelessWidget { const SizedBox(height: AppSpacing.md), for (int i = 5; i >= 0; i--) _YaoRow(index: i, type: yaoStates[i]), const SizedBox(height: AppSpacing.xs), - const Align(alignment: Alignment.centerLeft, child: YaoLegend()), ], ), ), diff --git a/apps/lib/features/divination/presentation/screens/divination_result_screen.dart b/apps/lib/features/divination/presentation/screens/divination_result_screen.dart index b8d42e4..9e816fe 100644 --- a/apps/lib/features/divination/presentation/screens/divination_result_screen.dart +++ b/apps/lib/features/divination/presentation/screens/divination_result_screen.dart @@ -10,7 +10,6 @@ import '../../../../shared/theme/app_color_palette.dart'; import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/widgets/divination/divination_terms.dart'; import '../../../../shared/widgets/divination/yao_glyph.dart'; -import '../../../../shared/widgets/divination/yao_legend.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import '../../data/apis/divination_api.dart'; @@ -926,11 +925,6 @@ class _HexagramDetailCard extends StatelessWidget { showTarget: data.hasChangingYao && idx < data.targetYaoLines.length, ), - const SizedBox(height: AppSpacing.sm), - const Align( - alignment: Alignment.centerLeft, - child: YaoLegend(), - ), ], ), ), @@ -1156,7 +1150,10 @@ class _YaoDetailRow extends StatelessWidget { ), SizedBox( width: 28, - child: Text(data.relation, textAlign: TextAlign.center), + child: Text( + _abbreviateRelation(data.relation), + textAlign: TextAlign.center, + ), ), SizedBox( width: 18, @@ -1183,4 +1180,15 @@ class _YaoDetailRow extends StatelessWidget { String _changeMark(YaoType type) { return type.changeMark; } + + String _abbreviateRelation(String relation) { + return switch (relation) { + '子孙' => '孙', + '妻财' => '财', + '官鬼' => '官', + '兄弟' => '兄', + '父母' => '父', + _ => relation, + }; + } } diff --git a/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart b/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart index def922a..16cce23 100644 --- a/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart +++ b/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart @@ -11,7 +11,6 @@ import '../../../../shared/widgets/app_modal_dialog.dart'; import '../../../../shared/widgets/gua_icon.dart'; import '../../../../shared/widgets/divination/divination_shared_widgets.dart'; import '../../../../shared/widgets/divination/divination_terms.dart'; -import '../../../../shared/widgets/divination/yao_legend.dart'; import '../../../../shared/widgets/divination/yao_line_row.dart'; import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart'; import '../../../../shared/widgets/toast/toast.dart'; @@ -20,6 +19,7 @@ import '../../data/models/divination_backend_models.dart'; import '../../data/apis/divination_api.dart'; import '../../data/models/divination_params.dart'; import '../../data/models/divination_result.dart'; +import '../../data/models/yao_coin_converter.dart'; import '../../data/services/divination_run_service.dart'; import 'divination_processing_screen.dart'; @@ -524,7 +524,6 @@ class _YaoSelectionCard extends StatelessWidget { ); }), const SizedBox(height: AppSpacing.xs), - const Align(alignment: Alignment.centerLeft, child: YaoLegend()), ], ), ), @@ -565,13 +564,7 @@ class _ThreeCoinSelectorDialogState extends State<_ThreeCoinSelectorDialog> { YaoType get _currentYaoType { final huaCount = _coinStates.where((isHua) => isHua).length; - return switch (huaCount) { - 0 => YaoType.oldYin, - 1 => YaoType.youngYang, - 2 => YaoType.youngYin, - 3 => YaoType.oldYang, - _ => YaoType.undetermined, - }; + return YaoCoinConverter.fromHuaCount(huaCount); } void _toggleCoin(int index) { diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart index 5701333..3e1ebdc 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../../../../core/auth/session_store.dart'; +import '../../../../data/network/api_client.dart'; import '../../../divination/presentation/screens/divination_screen.dart'; import '../../../divination/presentation/screens/divination_result_screen.dart'; import '../../../divination/data/apis/divination_api.dart'; @@ -11,8 +12,11 @@ import '../../../divination/data/models/divination_result.dart'; import '../../../notifications/data/repositories/notification_repository.dart'; import '../../../notifications/presentation/bloc/notification_bloc.dart'; import '../../../notifications/presentation/screens/notification_center_screen.dart'; +import '../../../settings/data/apis/invite_api.dart'; import '../../../settings/data/models/profile_settings.dart'; +import '../../../settings/data/repositories/invite_repository.dart'; import '../../../settings/presentation/screens/settings_screen.dart'; +import '../../../../app/di/injection.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/app_color_palette.dart'; import '../../../../shared/theme/design_tokens.dart'; @@ -68,10 +72,18 @@ class HomeScreen extends StatefulWidget { class _HomeScreenState extends State { MainTab _currentTab = MainTab.home; + late final InviteRepository _inviteRepository; @override void initState() { super.initState(); + final inviteApi = InviteApi( + apiClient: ApiClient( + baseUrl: appDependencies.backendUrl, + tokenProvider: widget.sessionStore.getToken, + ), + ); + _inviteRepository = InviteRepositoryImpl(inviteApi: inviteApi); WidgetsBinding.instance.addPostFrameCallback((_) { _tryShowWelcomeDialog(); }); @@ -120,6 +132,7 @@ class _HomeScreenState extends State { account: widget.account, settings: widget.profileSettings, coinBalance: widget.coinBalance, + inviteRepository: _inviteRepository, onLocaleChanged: widget.onLocaleChanged, onSettingsChanged: widget.onProfileSettingsChanged, onSaveProfile: widget.onSaveProfile, @@ -209,6 +222,11 @@ class _HomeTab extends StatelessWidget { MaterialPageRoute( builder: (_) => NotificationCenterScreen( repository: notificationRepository, + onUnreadCountChanged: () { + return notificationBloc.handleEvent( + RefreshUnreadCount(), + ); + }, ), ), ); @@ -532,6 +550,7 @@ class _ProfileTab extends StatelessWidget { required this.account, required this.settings, required this.coinBalance, + required this.inviteRepository, required this.onLocaleChanged, required this.onSettingsChanged, required this.onSaveProfile, @@ -543,6 +562,7 @@ class _ProfileTab extends StatelessWidget { final String account; final ProfileSettingsV1 settings; final int coinBalance; + final InviteRepository inviteRepository; final Future Function(String languageTag) onLocaleChanged; final Future Function(ProfileSettingsV1 settings) onSettingsChanged; final Future Function(ProfileSettingsV1 updated) @@ -557,6 +577,7 @@ class _ProfileTab extends StatelessWidget { account: account, settings: settings, coinBalance: coinBalance, + inviteRepository: inviteRepository, onInterfaceLanguageChanged: onLocaleChanged, onSettingsChanged: onSettingsChanged, onSaveProfile: onSaveProfile, diff --git a/apps/lib/features/notifications/data/apis/notification_api.dart b/apps/lib/features/notifications/data/apis/notification_api.dart index 05aaff0..32d08ec 100644 --- a/apps/lib/features/notifications/data/apis/notification_api.dart +++ b/apps/lib/features/notifications/data/apis/notification_api.dart @@ -60,11 +60,20 @@ class NotificationApi { } Future markRead({required String notificationId}) async { + _logger.info( + message: 'Mark read request started', + extra: {'notification_id': notificationId}, + ); try { final response = await _apiClient.rawDio.patch>( '/api/v1/notifications/$notificationId/read', ); - return parseNotificationItem(response.data!); + final item = 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) { _logger.error( message: 'Mark read failed', @@ -76,11 +85,17 @@ 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', ); - return response.data?['updatedCount'] as int? ?? 0; + final updatedCount = 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) { _logger.error( message: 'Mark all read failed', diff --git a/apps/lib/features/notifications/presentation/bloc/notification_bloc.dart b/apps/lib/features/notifications/presentation/bloc/notification_bloc.dart index 3710ea2..95ec8fe 100644 --- a/apps/lib/features/notifications/presentation/bloc/notification_bloc.dart +++ b/apps/lib/features/notifications/presentation/bloc/notification_bloc.dart @@ -185,58 +185,64 @@ class NotificationBloc extends ChangeNotifier { } Future _markRead(String notificationId) async { - final previousItems = _state.items; - final previousCount = _state.unreadCount; final idx = _state.items.indexWhere((item) => item.id == notificationId); if (idx == -1) return; + if (_state.items[idx].isRead) return; - final wasUnread = !_state.items[idx].isRead; - _state = _state.copyWith( - items: [ - ..._state.items.sublist(0, idx), - _state.items[idx].copyWith(isRead: true), - ..._state.items.sublist(idx + 1), - ], - unreadCount: wasUnread - ? (_state.unreadCount > 0 ? _state.unreadCount - 1 : 0) - : _state.unreadCount, + _logger.info( + message: 'Mark notification read started', + extra: {'notification_id': notificationId}, ); - notifyListeners(); try { - await _repository.markRead(notificationId: notificationId); + final updated = await _repository.markRead( + notificationId: notificationId, + ); + final targetIndex = _state.items.indexWhere( + (item) => item.id == updated.id, + ); + if (targetIndex == -1) { + return; + } + _state = _state.copyWith( + items: [ + ..._state.items.sublist(0, targetIndex), + updated, + ..._state.items.sublist(targetIndex + 1), + ], + unreadCount: _state.unreadCount > 0 ? _state.unreadCount - 1 : 0, + ); + notifyListeners(); + _logger.info( + message: 'Mark notification read succeeded', + extra: {'notification_id': notificationId}, + ); } catch (error, stackTrace) { _logger.error( message: 'Mark read failed: ${error.runtimeType}', error: error, stackTrace: stackTrace, ); - _state = _state.copyWith( - items: previousItems, - unreadCount: previousCount, - ); - notifyListeners(); } } Future _markAllRead() async { - final previousItems = _state.items; - _state = _state.copyWith( - items: _state.items.map((item) => item.copyWith(isRead: true)).toList(), - unreadCount: 0, - ); - notifyListeners(); + _logger.info(message: 'Mark all notifications read started'); try { await _repository.markAllRead(); + _state = _state.copyWith( + items: _state.items.map((item) => item.copyWith(isRead: true)).toList(), + unreadCount: 0, + ); + notifyListeners(); + _logger.info(message: 'Mark all notifications read succeeded'); } catch (error, stackTrace) { _logger.error( message: 'Mark all read failed: ${error.runtimeType}', error: error, stackTrace: stackTrace, ); - _state = _state.copyWith(items: previousItems); - notifyListeners(); } } 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 36ac16a..9b38c74 100644 --- a/apps/lib/features/notifications/presentation/screens/notification_center_screen.dart +++ b/apps/lib/features/notifications/presentation/screens/notification_center_screen.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/widgets/notification/notification_detail_bottom_sheet.dart'; import '../../data/models/notification_item.dart'; import '../../data/models/notification_payload.dart'; import '../../data/repositories/notification_repository.dart'; @@ -13,12 +16,14 @@ class NotificationCenterScreen extends StatefulWidget { required this.repository, this.onNavigateToRoute, this.onOpenUrl, + this.onUnreadCountChanged, }); final NotificationRepository repository; final void Function(String route, {String? entityId, String? tab})? onNavigateToRoute; final void Function(String url)? onOpenUrl; + final Future Function()? onUnreadCountChanged; @override State createState() => @@ -55,6 +60,7 @@ class _NotificationCenterScreenState extends State { return Scaffold( appBar: AppBar( title: const Text('通知'), + centerTitle: true, actions: [ if (state.items.any((item) => !item.isRead)) TextButton( @@ -136,15 +142,32 @@ class _NotificationCenterScreenState extends State { final item = state.items[index]; return NotificationListItem( item: item, - onTap: () => _handleNotificationTap(item), + onTap: () => _handleNotificationTap(context, item), ); }, ); } - void _handleNotificationTap(NotificationItem item) { + Future _handleNotificationTap( + BuildContext context, + NotificationItem item, + ) async { + final wasUnread = !item.isRead; if (!item.isRead) { - _bloc.handleEvent(MarkNotificationRead(notificationId: 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) { + await widget.onUnreadCountChanged?.call(); + } + } + if (context.mounted) { + await showNotificationDetailBottomSheet( + context: context, + item: item, + onMarkRead: () async {}, + ); } _executePayload(item.payload); } @@ -161,6 +184,15 @@ class _NotificationCenterScreenState extends State { } void _onMarkAllRead() { - _bloc.handleEvent(MarkAllNotificationsRead()); + unawaited(_markAllRead()); + } + + 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); + 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 43cf4db..978ddcf 100644 --- a/apps/lib/features/notifications/presentation/widgets/notification_list_item.dart +++ b/apps/lib/features/notifications/presentation/widgets/notification_list_item.dart @@ -18,73 +18,78 @@ class NotificationListItem extends StatelessWidget { final colors = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - 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, + 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, + ), ), ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!item.isRead) - Container( - margin: const EdgeInsets.only( - top: AppSpacing.sm, - right: AppSpacing.sm, + 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, + ), ), - 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( + _formatTime(item.createdAt), + style: textTheme.labelSmall?.copyWith( + color: colors.outline, + ), + ), + ], ), ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - 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, - ), - ), - ], - ), - ), - ], + ], + ), ), ), ); diff --git a/apps/lib/features/settings/data/apis/invite_api.dart b/apps/lib/features/settings/data/apis/invite_api.dart new file mode 100644 index 0000000..22c1eb2 --- /dev/null +++ b/apps/lib/features/settings/data/apis/invite_api.dart @@ -0,0 +1,50 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/logging/logger.dart'; +import '../../../../core/network/api_problem.dart'; +import '../../../../data/network/api_client.dart'; +import '../models/my_invite_code.dart'; + +class InviteApi { + InviteApi({required ApiClient apiClient}) : _apiClient = apiClient; + + final ApiClient _apiClient; + final Logger _logger = getLogger('features.settings.data.apis'); + + Future getMyInviteCode() async { + try { + final json = await _apiClient.getJson('/api/v1/invite/me'); + return MyInviteCode( + code: json['code'] as String, + usedCount: json['used_count'] as int, + ); + } on DioException catch (error, stackTrace) { + _logger.error( + message: 'Get my invite code failed', + error: error, + stackTrace: stackTrace, + ); + throw _mapProblem(error); + } + } + + ApiProblem _mapProblem(DioException error) { + final status = error.response?.statusCode ?? 500; + final data = error.response?.data; + + if (data is Map) { + return ApiProblem( + status: status, + title: (data['title'] as String?) ?? 'Request failed', + detail: (data['detail'] as String?) ?? '', + code: data['code'] as String?, + ); + } + + return ApiProblem( + status: status, + title: 'Network error', + detail: error.message ?? 'Request failed', + ); + } +} diff --git a/apps/lib/features/settings/data/models/my_invite_code.dart b/apps/lib/features/settings/data/models/my_invite_code.dart new file mode 100644 index 0000000..07f8f94 --- /dev/null +++ b/apps/lib/features/settings/data/models/my_invite_code.dart @@ -0,0 +1,6 @@ +class MyInviteCode { + const MyInviteCode({required this.code, required this.usedCount}); + + final String code; + final int usedCount; +} diff --git a/apps/lib/features/settings/data/repositories/invite_repository.dart b/apps/lib/features/settings/data/repositories/invite_repository.dart new file mode 100644 index 0000000..b01318c --- /dev/null +++ b/apps/lib/features/settings/data/repositories/invite_repository.dart @@ -0,0 +1,17 @@ +import '../apis/invite_api.dart'; +import '../models/my_invite_code.dart'; + +abstract class InviteRepository { + Future getMyInviteCode(); +} + +class InviteRepositoryImpl implements InviteRepository { + InviteRepositoryImpl({required InviteApi inviteApi}) : _inviteApi = inviteApi; + + final InviteApi _inviteApi; + + @override + Future getMyInviteCode() { + return _inviteApi.getMyInviteCode(); + } +} diff --git a/apps/lib/features/settings/presentation/screens/account_delete_screen.dart b/apps/lib/features/settings/presentation/screens/account_delete_screen.dart index b822412..0f9df41 100644 --- a/apps/lib/features/settings/presentation/screens/account_delete_screen.dart +++ b/apps/lib/features/settings/presentation/screens/account_delete_screen.dart @@ -208,6 +208,24 @@ class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> { height: 1.45, ), ), + const SizedBox(height: AppSpacing.sm), + Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: colors.errorContainer, + borderRadius: BorderRadius.circular(AppRadius.md), + border: Border.all(color: colors.error.withValues(alpha: 0.35)), + ), + child: Text( + l10n.settingsDeleteAccountReRegisterNotice, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onErrorContainer, + fontWeight: FontWeight.w700, + height: 1.35, + ), + ), + ), const SizedBox(height: AppSpacing.md), Text( _secondsLeft > 0 diff --git a/apps/lib/features/settings/presentation/screens/invite_screen.dart b/apps/lib/features/settings/presentation/screens/invite_screen.dart index 6c16134..0ccfbbb 100644 --- a/apps/lib/features/settings/presentation/screens/invite_screen.dart +++ b/apps/lib/features/settings/presentation/screens/invite_screen.dart @@ -1,44 +1,125 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../../../../core/logging/logger.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/app_color_palette.dart'; import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; +import '../../data/repositories/invite_repository.dart'; class InviteScreen extends StatefulWidget { - const InviteScreen({super.key}); + const InviteScreen({super.key, required this.inviteRepository}); + + final InviteRepository inviteRepository; @override State createState() => _InviteScreenState(); } class _InviteScreenState extends State { + final Logger _logger = getLogger('features.settings.invite_screen'); final _bindCodeController = TextEditingController(); final _formKey = GlobalKey(); bool _isBinding = false; bool _isGenerating = false; + bool _isLoading = true; + bool _hasError = false; - // Mock data - will be replaced with API calls - final String _myInviteCode = 'ABC123'; - final int _invitedCount = 3; + String? _myInviteCode; + int _invitedCount = 0; final bool _hasInviter = false; + @override + void initState() { + super.initState(); + _loadInviteCode(); + } + + Future _loadInviteCode() async { + setState(() { + _isLoading = true; + _hasError = false; + }); + try { + final result = await widget.inviteRepository.getMyInviteCode(); + if (!mounted) return; + setState(() { + _myInviteCode = result.code; + _invitedCount = result.usedCount; + _isLoading = false; + }); + } catch (error, stackTrace) { + _logger.error( + message: 'Failed to load invite code', + error: error, + stackTrace: stackTrace, + ); + if (!mounted) return; + setState(() { + _hasError = true; + _isLoading = false; + }); + } + } + @override void dispose() { _bindCodeController.dispose(); super.dispose(); } - bool get _hasMyInviteCode => _myInviteCode.isNotEmpty; + bool get _hasMyInviteCode => + _myInviteCode != null && _myInviteCode!.isNotEmpty; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final colors = Theme.of(context).colorScheme; + if (_isLoading) { + return Scaffold( + backgroundColor: colors.surfaceContainerLow, + appBar: AppBar( + title: Text(l10n.settingsInviteTitle), + centerTitle: true, + backgroundColor: colors.surfaceContainerLow, + surfaceTintColor: colors.surfaceContainerLow, + ), + body: const Center(child: CircularProgressIndicator()), + ); + } + + if (_hasError) { + return Scaffold( + backgroundColor: colors.surfaceContainerLow, + appBar: AppBar( + title: Text(l10n.settingsInviteTitle), + centerTitle: true, + backgroundColor: colors.surfaceContainerLow, + surfaceTintColor: colors.surfaceContainerLow, + ), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + l10n.settingsInviteEmptyTitle, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: AppSpacing.md), + FilledButton( + onPressed: _loadInviteCode, + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + return Scaffold( backgroundColor: colors.surfaceContainerLow, appBar: AppBar( @@ -51,7 +132,10 @@ class _InviteScreenState extends State { padding: const EdgeInsets.all(AppSpacing.lg), children: [ if (_hasMyInviteCode) ...[ - _InviteCodeCard(inviteCode: _myInviteCode, onCopy: _copyInviteCode), + _InviteCodeCard( + inviteCode: _myInviteCode!, + onCopy: _copyInviteCode, + ), const SizedBox(height: AppSpacing.lg), _InviteStatsCard(count: _invitedCount), const SizedBox(height: AppSpacing.xl), @@ -79,7 +163,7 @@ class _InviteScreenState extends State { void _copyInviteCode() { final l10n = AppLocalizations.of(context)!; - Clipboard.setData(ClipboardData(text: _myInviteCode)); + Clipboard.setData(ClipboardData(text: _myInviteCode!)); Toast.show( context, l10n.settingsInviteCopySuccess, diff --git a/apps/lib/features/settings/presentation/screens/settings_screen.dart b/apps/lib/features/settings/presentation/screens/settings_screen.dart index 4d8b0d3..0b68574 100644 --- a/apps/lib/features/settings/presentation/screens/settings_screen.dart +++ b/apps/lib/features/settings/presentation/screens/settings_screen.dart @@ -5,6 +5,7 @@ import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/widgets/app_modal_dialog.dart'; import '../../../../shared/widgets/gua_icon.dart'; import '../../data/models/profile_settings.dart'; +import '../../data/repositories/invite_repository.dart'; import 'account_delete_screen.dart'; import '../widgets/settings_section_widgets.dart'; import 'coin_center_screen.dart'; @@ -19,6 +20,7 @@ class SettingsScreen extends StatefulWidget { required this.account, required this.settings, required this.coinBalance, + required this.inviteRepository, required this.onInterfaceLanguageChanged, required this.onSettingsChanged, required this.onUploadAvatar, @@ -30,6 +32,7 @@ class SettingsScreen extends StatefulWidget { final String account; final ProfileSettingsV1 settings; final int coinBalance; + final InviteRepository inviteRepository; final Future Function(String languageTag) onInterfaceLanguageChanged; final Future Function(ProfileSettingsV1 settings) onSettingsChanged; final Future Function(String filePath) onUploadAvatar; @@ -179,9 +182,11 @@ class _SettingsScreenState extends State { } Future _openInvite() async { - await Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const InviteScreen())); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => InviteScreen(inviteRepository: widget.inviteRepository), + ), + ); } Future _openProfileEdit() async { diff --git a/apps/lib/l10n/app_en.arb b/apps/lib/l10n/app_en.arb index b0ccb7d..f89ae33 100644 --- a/apps/lib/l10n/app_en.arb +++ b/apps/lib/l10n/app_en.arb @@ -146,6 +146,7 @@ "settingsDeleteAccountSubtitle": "Permanently delete your account and personal data", "settingsDeleteAccountWarningTitle": "Please confirm before deleting", "settingsDeleteAccountWarningBody": "After deletion, related data including profile, history, and points will be permanently removed and cannot be restored.", + "settingsDeleteAccountReRegisterNotice": "Important: if you delete and re-register with the same email, consumed points will not be reset or refunded.", "settingsDeleteAccountScopeProfile": "Profile and account information will be deleted", "settingsDeleteAccountScopeHistory": "Divination history records will be deleted", "settingsDeleteAccountScopePoints": "Points account and ledger records will be deleted", @@ -295,7 +296,7 @@ "questionTypeSearch": "Search", "questionTypeOther": "Other", "toastPleaseInputQuestion": "Please enter your question", - "toastCoinInsufficient": "Insufficient coins", + "toastCoinInsufficient": "Insufficient points", "divinationCostDialogTitle": "Confirm divination", "divinationCostDialogBody": "This run costs {cost} credits. Available balance: {balance} credits. Continue?", "@divinationCostDialogBody": { diff --git a/apps/lib/l10n/app_localizations.dart b/apps/lib/l10n/app_localizations.dart index 4cff5af..385ed5f 100644 --- a/apps/lib/l10n/app_localizations.dart +++ b/apps/lib/l10n/app_localizations.dart @@ -758,6 +758,12 @@ abstract class AppLocalizations { /// **'删除账号后,个人资料、历史记录、点数信息等相关数据将被永久删除,且不可恢复。'** String get settingsDeleteAccountWarningBody; + /// No description provided for @settingsDeleteAccountReRegisterNotice. + /// + /// In zh, this message translates to: + /// **'重要提示:同一邮箱删除后重新注册,已消耗积分不会重置或返还。'** + String get settingsDeleteAccountReRegisterNotice; + /// No description provided for @settingsDeleteAccountScopeProfile. /// /// In zh, this message translates to: @@ -1487,7 +1493,7 @@ abstract class AppLocalizations { /// No description provided for @toastCoinInsufficient. /// /// In zh, this message translates to: - /// **'铜钱不足,无法解卦'** + /// **'积分不足,无法解卦'** String get toastCoinInsufficient; /// No description provided for @divinationCostDialogTitle. diff --git a/apps/lib/l10n/app_localizations_en.dart b/apps/lib/l10n/app_localizations_en.dart index eebed96..9255312 100644 --- a/apps/lib/l10n/app_localizations_en.dart +++ b/apps/lib/l10n/app_localizations_en.dart @@ -367,6 +367,10 @@ class AppLocalizationsEn extends AppLocalizations { String get settingsDeleteAccountWarningBody => 'After deletion, related data including profile, history, and points will be permanently removed and cannot be restored.'; + @override + String get settingsDeleteAccountReRegisterNotice => + 'Important: if you delete and re-register with the same email, consumed points will not be reset or refunded.'; + @override String get settingsDeleteAccountScopeProfile => 'Profile and account information will be deleted'; @@ -770,7 +774,7 @@ class AppLocalizationsEn extends AppLocalizations { String get toastPleaseInputQuestion => 'Please enter your question'; @override - String get toastCoinInsufficient => 'Insufficient coins'; + String get toastCoinInsufficient => 'Insufficient points'; @override String get divinationCostDialogTitle => 'Confirm divination'; diff --git a/apps/lib/l10n/app_localizations_zh.dart b/apps/lib/l10n/app_localizations_zh.dart index e60c685..182d33e 100644 --- a/apps/lib/l10n/app_localizations_zh.dart +++ b/apps/lib/l10n/app_localizations_zh.dart @@ -359,6 +359,10 @@ class AppLocalizationsZh extends AppLocalizations { String get settingsDeleteAccountWarningBody => '删除账号后,个人资料、历史记录、点数信息等相关数据将被永久删除,且不可恢复。'; + @override + String get settingsDeleteAccountReRegisterNotice => + '重要提示:同一邮箱删除后重新注册,已消耗积分不会重置或返还。'; + @override String get settingsDeleteAccountScopeProfile => '个人资料和账号信息会被删除'; @@ -737,7 +741,7 @@ class AppLocalizationsZh extends AppLocalizations { String get toastPleaseInputQuestion => '请输入您想占卜的问题'; @override - String get toastCoinInsufficient => '铜钱不足,无法解卦'; + String get toastCoinInsufficient => '积分不足,无法解卦'; @override String get divinationCostDialogTitle => '确认开始解卦'; diff --git a/apps/lib/l10n/app_zh.arb b/apps/lib/l10n/app_zh.arb index 06b6728..1664876 100644 --- a/apps/lib/l10n/app_zh.arb +++ b/apps/lib/l10n/app_zh.arb @@ -146,6 +146,7 @@ "settingsDeleteAccountSubtitle": "永久删除账号及相关个人数据", "settingsDeleteAccountWarningTitle": "删除前请确认", "settingsDeleteAccountWarningBody": "删除账号后,个人资料、历史记录、点数信息等相关数据将被永久删除,且不可恢复。", + "settingsDeleteAccountReRegisterNotice": "重要提示:同一邮箱删除后重新注册,已消耗积分不会重置或返还。", "settingsDeleteAccountScopeProfile": "个人资料和账号信息会被删除", "settingsDeleteAccountScopeHistory": "历史解卦记录会被删除", "settingsDeleteAccountScopePoints": "点数账户与流水记录会被删除", @@ -295,7 +296,7 @@ "questionTypeSearch": "寻物", "questionTypeOther": "其他", "toastPleaseInputQuestion": "请输入您想占卜的问题", - "toastCoinInsufficient": "铜钱不足,无法解卦", + "toastCoinInsufficient": "积分不足,无法解卦", "divinationCostDialogTitle": "确认开始解卦", "divinationCostDialogBody": "本次解卦将消耗 {cost} 点数,当前可用 {balance} 点数。是否继续?", "@divinationCostDialogBody": { diff --git a/apps/lib/shared/widgets/notification/notification_detail_bottom_sheet.dart b/apps/lib/shared/widgets/notification/notification_detail_bottom_sheet.dart new file mode 100644 index 0000000..04b5761 --- /dev/null +++ b/apps/lib/shared/widgets/notification/notification_detail_bottom_sheet.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; + +import '../../../features/notifications/data/models/notification_item.dart'; +import '../../theme/design_tokens.dart'; + +class NotificationDetailBottomSheet extends StatefulWidget { + const NotificationDetailBottomSheet({ + super.key, + required this.item, + required this.onMarkRead, + }); + + final NotificationItem item; + final Future Function() onMarkRead; + + @override + State createState() => + _NotificationDetailBottomSheetState(); +} + +class _NotificationDetailBottomSheetState + extends State { + @override + void initState() { + super.initState(); + widget.onMarkRead(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Container( + height: MediaQuery.of(context).size.height * 0.5, + decoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(AppRadius.lg), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + margin: const EdgeInsets.only(top: AppSpacing.sm), + width: 40, + height: 4, + decoration: BoxDecoration( + color: colors.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Row( + children: [ + Expanded( + child: Text( + widget.item.title, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + ), + IconButton( + onPressed: () => Navigator.pop(context), + icon: Icon(Icons.close, color: colors.onSurfaceVariant), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + child: Text( + _formatTime(widget.item.createdAt), + style: textTheme.labelSmall?.copyWith(color: colors.outline), + ), + ), + const SizedBox(height: AppSpacing.md), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), + child: Text( + widget.item.body, + style: textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + height: 1.6, + ), + ), + ), + ), + ], + ), + ); + } + + 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({ + required BuildContext context, + required NotificationItem item, + required Future Function() onMarkRead, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => + NotificationDetailBottomSheet(item: item, onMarkRead: onMarkRead), + ); +} diff --git a/apps/test/features/notifications/notification_bloc_test.dart b/apps/test/features/notifications/notification_bloc_test.dart index 82769ce..5a00728 100644 --- a/apps/test/features/notifications/notification_bloc_test.dart +++ b/apps/test/features/notifications/notification_bloc_test.dart @@ -10,6 +10,8 @@ class _FakeNotificationRepository implements NotificationRepository { final List items = []; int unreadCount = 0; int markAllReadCallCount = 0; + bool failMarkRead = false; + bool failMarkAllRead = false; @override Future listNotifications({ @@ -28,6 +30,9 @@ class _FakeNotificationRepository implements NotificationRepository { @override Future markRead({required String notificationId}) async { + if (failMarkRead) { + throw Exception('Mark read failed'); + } final idx = items.indexWhere((i) => i.id == notificationId); if (idx == -1) { throw Exception('Not found'); @@ -39,6 +44,9 @@ class _FakeNotificationRepository implements NotificationRepository { @override Future markAllRead() async { + if (failMarkAllRead) { + throw Exception('Mark all read failed'); + } markAllReadCallCount++; final count = unreadCount; for (int i = 0; i < items.length; i++) { @@ -99,6 +107,21 @@ void main() { expect(bloc.state.unreadCount, 0); }); + test( + 'MarkNotificationRead does not update state when request fails', + () async { + fakeRepo.items.add(makeItem(id: 'n1', isRead: false)); + fakeRepo.unreadCount = 1; + fakeRepo.failMarkRead = true; + await bloc.handleEvent(LoadNotifications()); + await bloc.handleEvent(RefreshUnreadCount()); + + await bloc.handleEvent(MarkNotificationRead(notificationId: 'n1')); + expect(bloc.state.items.first.isRead, false); + expect(bloc.state.unreadCount, 1); + }, + ); + test('MarkAllNotificationsRead marks all as read', () async { fakeRepo.items.addAll([ makeItem(id: 'n1', isRead: false), @@ -112,6 +135,24 @@ void main() { expect(bloc.state.items.every((i) => i.isRead), true); }); + test( + 'MarkAllNotificationsRead does not update state when request fails', + () async { + fakeRepo.items.addAll([ + makeItem(id: 'n1', isRead: false), + makeItem(id: 'n2', isRead: false), + ]); + fakeRepo.unreadCount = 2; + fakeRepo.failMarkAllRead = true; + await bloc.handleEvent(LoadNotifications()); + await bloc.handleEvent(RefreshUnreadCount()); + + await bloc.handleEvent(MarkAllNotificationsRead()); + expect(bloc.state.unreadCount, 2); + expect(bloc.state.items.every((i) => !i.isRead), true); + }, + ); + test( 'NotificationCreatedEvent adds item and increments unreadCount', () async { diff --git a/apps/tool/run-dev.sh b/apps/tool/run-dev.sh deleted file mode 100755 index 738994d..0000000 --- a/apps/tool/run-dev.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -BACKEND_URL="" -DEVICE_ARGS=() - -usage() { - cat < dict[str, object]: runtime_config=runtime_config, cancel_checker=_cancel_checker, ) - await points_service.consume_successful_run_points( - user_id=owner_id, - session_id=UUID(thread_id), - run_id=run_id, - operator_id=owner_id, - user_email=owner_email, - ) + if runtime_mode == RuntimeMode.CHAT: + await points_service.consume_successful_run_points( + user_id=owner_id, + session_id=UUID(thread_id), + run_id=run_id, + operator_id=owner_id, + user_email=owner_email, + ) await session.commit() except asyncio.CancelledError: await points_service.record_failed_run_platform_cost( diff --git a/backend/src/core/config/notification/static_schema.py b/backend/src/core/config/notification/static_schema.py index 309f1ae..c5e81fb 100644 --- a/backend/src/core/config/notification/static_schema.py +++ b/backend/src/core/config/notification/static_schema.py @@ -9,7 +9,7 @@ from uuid import UUID import yaml from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator -from v1.notifications.schemas import ( +from backend.src.schemas.shared.notification import ( NotificationPayload, NotificationPayloadNone, ) diff --git a/backend/src/core/logging/config.py b/backend/src/core/logging/config.py index 4e97122..bca3c16 100644 --- a/backend/src/core/logging/config.py +++ b/backend/src/core/logging/config.py @@ -39,6 +39,7 @@ def build_logging_config(runtime: RuntimeSettings) -> dict[str, object]: file_path=log_dir / runtime.log_file_name, level=runtime.log_level, formatter=formatter_name, + filters=["suppress_httpx_auth_noise"], ) error_handler = build_file_handler_config( runtime, @@ -54,7 +55,10 @@ def build_logging_config(runtime: RuntimeSettings) -> dict[str, object]: "filters": { "error_only": { "()": "core.logging.filters.ErrorLevelFilter", - } + }, + "suppress_httpx_auth_noise": { + "()": "core.logging.filters.HttpxAuthNoiseFilter", + }, }, "formatters": { "json": { diff --git a/backend/src/core/logging/filters.py b/backend/src/core/logging/filters.py index 2139c7a..3f7a071 100644 --- a/backend/src/core/logging/filters.py +++ b/backend/src/core/logging/filters.py @@ -54,3 +54,16 @@ def build_sensitive_data_processor( class ErrorLevelFilter(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: return record.levelno >= logging.ERROR + + +class HttpxAuthNoiseFilter(logging.Filter): + _SUPPRESSED_FRAGMENTS = ( + "/auth/v1/user", + "/auth/v1/token?grant_type=refresh_token", + ) + + def filter(self, record: logging.LogRecord) -> bool: + if record.levelno >= logging.WARNING: + return True + message = record.getMessage() + return not any(fragment in message for fragment in self._SUPPRESSED_FRAGMENTS) diff --git a/backend/src/schemas/domain/divination.py b/backend/src/schemas/domain/divination.py index d7a8a15..ea04fe3 100644 --- a/backend/src/schemas/domain/divination.py +++ b/backend/src/schemas/domain/divination.py @@ -25,7 +25,7 @@ class SpecialMark(str, Enum): class YaoDetail(BaseModel): - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", populate_by_name=True) position: int = Field(ge=1, le=6) spirit_name: str = Field(alias="spiritName", min_length=1) @@ -38,7 +38,7 @@ class YaoDetail(BaseModel): class FushenDetail(BaseModel): - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", populate_by_name=True) position: int = Field(ge=1, le=6) relation_name: str = Field(alias="relationName", min_length=1) @@ -47,7 +47,7 @@ class FushenDetail(BaseModel): class GanzhiDetail(BaseModel): - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", populate_by_name=True) year_gan_zhi: str = Field(alias="yearGanZhi", min_length=2, max_length=2) month_gan_zhi: str = Field(alias="monthGanZhi", min_length=2, max_length=2) diff --git a/backend/src/schemas/shared/notification.py b/backend/src/schemas/shared/notification.py new file mode 100644 index 0000000..f6ae413 --- /dev/null +++ b/backend/src/schemas/shared/notification.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import ClassVar, Literal, Union + +from pydantic import BaseModel, ConfigDict, Field + + +class NotificationPayloadNone(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + action: Literal["none"] + + +class NotificationPayloadRoute(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + action: Literal["open_route"] + route: str = Field(max_length=200) + entity_id: str | None = Field(default=None, max_length=64) + tab: str | None = Field(default=None, max_length=32) + + +class NotificationPayloadUrl(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + action: Literal["open_url"] + url: str = Field(max_length=500) + + +NotificationPayload = Union[ + NotificationPayloadNone, + NotificationPayloadRoute, + NotificationPayloadUrl, +] diff --git a/backend/src/v1/agent/repository.py b/backend/src/v1/agent/repository.py index 6b7f9bc..8f5305b 100644 --- a/backend/src/v1/agent/repository.py +++ b/backend/src/v1/agent/repository.py @@ -170,7 +170,7 @@ class AgentRepository: session_row.last_activity_at = datetime.now(timezone.utc) await self._session.flush() - async def get_user_message_count(self, *, session_id: str) -> int: + async def get_assistant_message_count(self, *, session_id: str) -> int: try: session_uuid = UUID(session_id) except ValueError as exc: @@ -184,7 +184,7 @@ class AgentRepository: select(func.count(AgentChatMessage.id)) .where(AgentChatMessage.session_id == session_uuid) .where(AgentChatMessage.deleted_at.is_(None)) - .where(AgentChatMessage.role == AgentChatMessageRole.USER) + .where(AgentChatMessage.role == AgentChatMessageRole.ASSISTANT) ) count = (await self._session.execute(stmt)).scalar_one() return int(count) @@ -266,7 +266,11 @@ class AgentRepository: ).scalar_one_or_none() is not None snapshot_messages: list[dict[str, object]] = [] for message in messages: - snapshot_messages.append(await self._to_snapshot_message(message)) + snapshot_messages.append( + (await self._to_chat_message_schema(message)).model_dump( + mode="json", by_alias=True, exclude_none=True + ) + ) return { "day": target_day.isoformat(), "hasMore": has_more, @@ -278,7 +282,7 @@ class AgentRepository: *, session_id: str, visibility_mask: int | None = None, - ) -> list[dict[str, object]]: + ) -> list[AgentChatMessageSchema]: try: session_uuid = UUID(session_id) except ValueError as exc: @@ -299,9 +303,9 @@ class AgentRepository: visibility_mask=visibility_mask, ) messages = (await self._session.execute(message_stmt)).scalars().all() - snapshot_messages: list[dict[str, object]] = [] + snapshot_messages: list[AgentChatMessageSchema] = [] for message in messages: - snapshot_messages.append(await self._to_snapshot_message(message)) + snapshot_messages.append(await self._to_chat_message_schema(message)) return snapshot_messages async def get_recent_messages_by_user_window( @@ -352,7 +356,11 @@ class AgentRepository: selected = list(reversed(selected_desc)) snapshot_messages: list[dict[str, object]] = [] for message in selected: - snapshot_messages.append(await self._to_snapshot_message(message)) + snapshot_messages.append( + (await self._to_chat_message_schema(message)).model_dump( + mode="json", by_alias=True, exclude_none=True + ) + ) return snapshot_messages async def get_latest_session_id_for_user(self, *, user_id: str) -> str | None: @@ -382,7 +390,7 @@ class AgentRepository: user_id: str, visibility_mask: int | None = None, session_limit: int = 50, - ) -> list[dict[str, object]]: + ) -> list[AgentChatMessageSchema]: try: user_uuid = UUID(user_id) except ValueError as exc: @@ -404,7 +412,7 @@ class AgentRepository: if not session_ids: return [] - snapshots: list[dict[str, object]] = [] + snapshots: list[AgentChatMessageSchema] = [] for session_id in session_ids: message_stmt = ( select(AgentChatMessage) @@ -423,10 +431,14 @@ class AgentRepository: ) if not candidate_messages: continue - selected_snapshot: dict[str, object] | None = None + selected_snapshot: AgentChatMessageSchema | None = None for message in candidate_messages: - snapshot = await self._to_snapshot_message(message) - metadata = snapshot.get("metadata") + snapshot = await self._to_chat_message_schema(message) + metadata = ( + snapshot.metadata.model_dump(mode="json", exclude_none=True) + if snapshot.metadata is not None + else None + ) if not isinstance(metadata, dict): continue agent_output = metadata.get("agent_output") @@ -440,7 +452,7 @@ class AgentRepository: snapshots.append(selected_snapshot) snapshots.sort( - key=lambda item: str(item.get("timestamp") or ""), + key=lambda item: str(item.timestamp), reverse=True, ) return snapshots @@ -462,9 +474,9 @@ class AgentRepository: "config": config_payload, } - async def _to_snapshot_message( + async def _to_chat_message_schema( self, message: AgentChatMessage - ) -> dict[str, object]: + ) -> AgentChatMessageSchema: role = ( message.role.value if isinstance(message.role, AgentChatMessageRole) @@ -487,7 +499,7 @@ class AgentRepository: "timestamp": message.created_at.astimezone(timezone.utc).isoformat(), } ) - return payload_model.model_dump(mode="json", exclude_none=True) + return payload_model def _apply_visibility_filter( self, diff --git a/backend/src/v1/agent/schemas.py b/backend/src/v1/agent/schemas.py index fbf15ad..4a3b99c 100644 --- a/backend/src/v1/agent/schemas.py +++ b/backend/src/v1/agent/schemas.py @@ -8,6 +8,7 @@ from uuid import UUID from pydantic import BaseModel, ConfigDict, Field from schemas.agent.runtime_models import ErrorInfo +from schemas.domain.chat_message import AgentChatMessage from schemas.domain.divination import DerivedDivinationData @@ -37,7 +38,7 @@ class AgentRepositoryLike(Protocol): *, session_id: str, visibility_mask: int | None = None, - ) -> list[dict[str, object]]: ... + ) -> list[AgentChatMessage]: ... async def get_latest_session_id_for_user(self, *, user_id: str) -> str | None: ... @@ -47,7 +48,7 @@ class AgentRepositoryLike(Protocol): user_id: str, visibility_mask: int | None = None, session_limit: int = 50, - ) -> list[dict[str, object]]: ... + ) -> list[AgentChatMessage]: ... async def persist_user_message( self, @@ -58,7 +59,7 @@ class AgentRepositoryLike(Protocol): visibility_mask: int, ) -> None: ... - async def get_user_message_count(self, *, session_id: str) -> int: ... + async def get_assistant_message_count(self, *, session_id: str) -> int: ... async def get_system_agent_config( self, *, agent_type: str diff --git a/backend/src/v1/agent/service.py b/backend/src/v1/agent/service.py index f5efea2..353056a 100644 --- a/backend/src/v1/agent/service.py +++ b/backend/src/v1/agent/service.py @@ -46,7 +46,7 @@ from v1.agent.utils import ( ) logger = get_logger(__name__) -MAX_RUNS_PER_SESSION = 2 +MAX_ASSISTANT_MESSAGES_PER_SESSION = 2 def ensure_session_owner(*, owner_id: str, current_user: CurrentUser) -> None: @@ -151,6 +151,7 @@ class AgentService: await self._enforce_run_preconditions( thread_id=thread_id, current_user=current_user, + runtime_mode=runtime_mode, ) except ApiProblemError: if created: @@ -247,7 +248,7 @@ class AgentService: metadata: AgentChatMessageMetadata | None, ) -> None: metadata_payload = ( - metadata.model_dump(mode="json", exclude_none=True) + metadata.model_dump(mode="json", by_alias=True, exclude_none=True) if isinstance(metadata, AgentChatMessageMetadata) else None ) @@ -494,19 +495,23 @@ class AgentService: *, thread_id: str, current_user: CurrentUser, + runtime_mode: RuntimeMode, ) -> None: - await self._points_service.ensure_run_points_available(user_id=current_user.id) + if runtime_mode == RuntimeMode.CHAT: + await self._points_service.ensure_run_points_available( + user_id=current_user.id + ) - user_message_count = await self._repository.get_user_message_count( + assistant_message_count = await self._repository.get_assistant_message_count( session_id=thread_id ) - if user_message_count >= MAX_RUNS_PER_SESSION: + if assistant_message_count >= MAX_ASSISTANT_MESSAGES_PER_SESSION: raise ApiProblemError( status_code=409, detail=problem_payload( code="AGENT_SESSION_RUN_LIMIT_EXCEEDED", detail="Session run limit exceeded", - params={"maxRuns": MAX_RUNS_PER_SESSION}, + params={"maxRuns": MAX_ASSISTANT_MESSAGES_PER_SESSION}, ), ) @@ -597,7 +602,6 @@ class AgentService: thread_id: str, current_user: CurrentUser, ) -> HistorySnapshotResponse: - from schemas.domain.chat_message import AgentChatMessage from v1.agent.utils import convert_message_to_history from v1.agent.schemas import HistoryMessage @@ -609,11 +613,9 @@ class AgentService: ) messages: list[HistoryMessage] = [] - for msg_dict in raw_messages: - msg = AgentChatMessage.model_validate(msg_dict) - if msg.role == "tool": + for msg in raw_messages: + if msg.role not in {"user", "assistant"}: continue - signed_urls: dict[str, str] = {} attachments = extract_user_message_attachments(msg.metadata) if self._attachment_storage and attachments: @@ -653,7 +655,6 @@ class AgentService: current_user: CurrentUser, thread_id: str | None, ) -> HistorySnapshotResponse: - from schemas.domain.chat_message import AgentChatMessage from v1.agent.utils import convert_message_to_history from v1.agent.schemas import HistoryMessage @@ -675,8 +676,9 @@ class AgentService: visible_messages = raw_messages[:summary_limit] messages: list[HistoryMessage] = [] - for msg_dict in visible_messages: - msg = AgentChatMessage.model_validate(msg_dict) + for msg in visible_messages: + if msg.role != "assistant": + continue converted = convert_message_to_history(msg) messages.append(HistoryMessage.model_validate(converted)) diff --git a/backend/src/v1/auth/router.py b/backend/src/v1/auth/router.py index 73d2f5f..bc2bdfa 100644 --- a/backend/src/v1/auth/router.py +++ b/backend/src/v1/auth/router.py @@ -7,6 +7,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.config.settings import config from core.db import get_db +from core.logging import get_logger +from v1.notifications.repository import NotificationRepository +from v1.notifications.service import NotificationService from v1.auth.rate_limit import enforce_rate_limit from v1.auth.dependencies import get_auth_service from v1.auth.schemas import ( @@ -22,6 +25,7 @@ from v1.points.service import PointsService router = APIRouter(prefix="/auth", tags=["auth"]) +logger = get_logger("v1.auth.router") @router.post("/otp/send", status_code=204) @@ -73,7 +77,16 @@ async def create_email_session( user_id=UUID(result.user.id), user_email=result.user.email, ) + notification_service = NotificationService(NotificationRepository(session)) + linked_count = await notification_service.link_published_notifications_to_user( + user_id=UUID(result.user.id) + ) await session.commit() + logger.info( + "Linked published notifications for authenticated user", + user_id=result.user.id, + linked_count=linked_count, + ) return result diff --git a/backend/src/v1/invite/dependencies.py b/backend/src/v1/invite/dependencies.py new file mode 100644 index 0000000..c668a14 --- /dev/null +++ b/backend/src/v1/invite/dependencies.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from core.auth.models import CurrentUser +from core.db import get_db +from v1.invite.repository import InviteCodeRepository +from v1.invite.service import InviteCodeService +from v1.users.dependencies import get_current_user + + +def get_invite_code_repository( + session: Annotated[AsyncSession, Depends(get_db)], +) -> InviteCodeRepository: + return InviteCodeRepository(session) + + +def get_invite_code_service( + repository: Annotated[InviteCodeRepository, Depends(get_invite_code_repository)], +) -> InviteCodeService: + return InviteCodeService(repository=repository) + + +def get_current_user_for_invite( + current_user: Annotated[CurrentUser, Depends(get_current_user)], +) -> CurrentUser: + return current_user diff --git a/backend/src/v1/invite/repository.py b/backend/src/v1/invite/repository.py new file mode 100644 index 0000000..b01f6e7 --- /dev/null +++ b/backend/src/v1/invite/repository.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from models.invite_code import InviteCode + + +class InviteCodeRepository: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_owner_id(self, *, owner_id: UUID) -> InviteCode | None: + stmt = ( + select(InviteCode) + .where(InviteCode.owner_id == owner_id) + .order_by(InviteCode.created_at.desc()) + .limit(1) + ) + return (await self._session.execute(stmt)).scalar_one_or_none() diff --git a/backend/src/v1/invite/router.py b/backend/src/v1/invite/router.py new file mode 100644 index 0000000..407a3f5 --- /dev/null +++ b/backend/src/v1/invite/router.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends + +from core.auth.models import CurrentUser +from v1.invite.dependencies import ( + get_current_user_for_invite, + get_invite_code_service, +) +from v1.invite.schemas import MyInviteCodeResponse +from v1.invite.service import InviteCodeService + + +router = APIRouter(prefix="/invite", tags=["invite"]) + + +@router.get("/me", response_model=MyInviteCodeResponse) +async def get_my_invite_code( + current_user: Annotated[CurrentUser, Depends(get_current_user_for_invite)], + service: Annotated[InviteCodeService, Depends(get_invite_code_service)], +) -> MyInviteCodeResponse: + return await service.get_my_invite_code(user_id=current_user.id) diff --git a/backend/src/v1/invite/schemas.py b/backend/src/v1/invite/schemas.py new file mode 100644 index 0000000..5f20082 --- /dev/null +++ b/backend/src/v1/invite/schemas.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + + +class MyInviteCodeResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + code: str + used_count: int diff --git a/backend/src/v1/invite/service.py b/backend/src/v1/invite/service.py new file mode 100644 index 0000000..ec7963a --- /dev/null +++ b/backend/src/v1/invite/service.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from core.http.errors import ApiProblemError, problem_payload +from v1.invite.repository import InviteCodeRepository +from v1.invite.schemas import MyInviteCodeResponse + + +@dataclass +class InviteCodeService: + repository: InviteCodeRepository + + async def get_my_invite_code(self, *, user_id: UUID) -> MyInviteCodeResponse: + invite_code = await self.repository.get_by_owner_id(owner_id=user_id) + if invite_code is None: + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="INVITE_CODE_NOT_FOUND", + detail="Invite code not found for current user", + ), + ) + return MyInviteCodeResponse( + code=invite_code.code, + used_count=invite_code.used_count, + ) diff --git a/backend/src/v1/notifications/repository.py b/backend/src/v1/notifications/repository.py index 812e198..c002225 100644 --- a/backend/src/v1/notifications/repository.py +++ b/backend/src/v1/notifications/repository.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime from uuid import UUID +from sqlalchemy.dialects.postgresql import insert from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -111,3 +112,37 @@ class NotificationRepository: await self._session.execute(stmt) await self._session.flush() return count + + async def commit(self) -> None: + await self._session.commit() + + async def link_published_notifications_to_user(self, *, user_id: UUID) -> int: + notification_ids = list( + ( + await self._session.execute( + select(Notification.id).where( + Notification.status == "published", + Notification.deleted_at.is_(None), + ) + ) + ) + .scalars() + .all() + ) + if not notification_ids: + return 0 + + stmt = ( + insert(UserNotification) + .values( + [ + {"user_id": user_id, "notification_id": notification_id} + for notification_id in notification_ids + ] + ) + .on_conflict_do_nothing(index_elements=["user_id", "notification_id"]) + .returning(UserNotification.id) + ) + result = await self._session.execute(stmt) + await self._session.flush() + return len(list(result.scalars().all())) diff --git a/backend/src/v1/notifications/router.py b/backend/src/v1/notifications/router.py index 63f1953..35418cb 100644 --- a/backend/src/v1/notifications/router.py +++ b/backend/src/v1/notifications/router.py @@ -4,6 +4,7 @@ from typing import Annotated from fastapi import APIRouter, Depends, Query +from core.logging import get_logger from core.auth.models import CurrentUser from v1.notifications.dependencies import get_notification_service from v1.notifications.schemas import ( @@ -16,6 +17,7 @@ from v1.notifications.service import NotificationService from v1.users.dependencies import get_current_user router = APIRouter(prefix="/notifications", tags=["notifications"]) +logger = get_logger("v1.notifications.router") @router.get("", response_model=NotificationListResponse) @@ -39,6 +41,13 @@ async def list_notifications( limit=limit, cursor=parsed_cursor, ) + logger.info( + "Notification list fetched", + user_id=str(current_user.id), + limit=limit, + item_count=len(result.items), + has_more=result.has_more, + ) items = [] for item in result.items: items.append( @@ -67,6 +76,11 @@ async def get_unread_count( current_user: Annotated[CurrentUser, Depends(get_current_user)], ) -> UnreadCountResponse: count = await service.get_unread_count(user_id=current_user.id) + logger.info( + "Notification unread count fetched", + user_id=str(current_user.id), + count=count, + ) return UnreadCountResponse(count=count) @@ -95,6 +109,11 @@ async def mark_notification_read( user_notification_id=uid, user_id=current_user.id, ) + logger.info( + "Notification marked as read", + user_id=str(current_user.id), + user_notification_id=str(uid), + ) return NotificationItemResponse( id=str(item.id), notificationId=str(item.notification_id), @@ -114,4 +133,9 @@ async def mark_all_read( current_user: Annotated[CurrentUser, Depends(get_current_user)], ) -> MarkAllReadResponse: updated_count = await service.mark_all_read(user_id=current_user.id) + logger.info( + "All notifications marked as read", + user_id=str(current_user.id), + updated_count=updated_count, + ) return MarkAllReadResponse(updatedCount=updated_count) diff --git a/backend/src/v1/notifications/schemas.py b/backend/src/v1/notifications/schemas.py index 5907c95..7b8ae83 100644 --- a/backend/src/v1/notifications/schemas.py +++ b/backend/src/v1/notifications/schemas.py @@ -1,35 +1,21 @@ from __future__ import annotations from datetime import datetime -from typing import Literal, Union from pydantic import BaseModel, ConfigDict, Field +from schemas.shared.notification import ( + NotificationPayload, + NotificationPayloadNone, + NotificationPayloadRoute, + NotificationPayloadUrl, +) -class NotificationPayloadNone(BaseModel): - model_config = ConfigDict(extra="forbid") - - action: Literal["none"] - - -class NotificationPayloadRoute(BaseModel): - model_config = ConfigDict(extra="forbid") - - action: Literal["open_route"] - route: str = Field(max_length=200) - entity_id: str | None = Field(default=None, max_length=64) - tab: str | None = Field(default=None, max_length=32) - - -class NotificationPayloadUrl(BaseModel): - model_config = ConfigDict(extra="forbid") - - action: Literal["open_url"] - url: str = Field(max_length=500) - - -NotificationPayload = Union[ - NotificationPayloadNone, NotificationPayloadRoute, NotificationPayloadUrl +__all__ = [ + "NotificationPayload", + "NotificationPayloadNone", + "NotificationPayloadRoute", + "NotificationPayloadUrl", ] diff --git a/backend/src/v1/notifications/service.py b/backend/src/v1/notifications/service.py index ed8cc11..640c90e 100644 --- a/backend/src/v1/notifications/service.py +++ b/backend/src/v1/notifications/service.py @@ -103,6 +103,7 @@ class NotificationService: user_notification_id=user_notification_id, user_id=user_id, ) + await self._repository.commit() payload = _parse_payload(n.payload) return NotificationListItem( id=un.id, @@ -117,7 +118,15 @@ class NotificationService: ) async def mark_all_read(self, *, user_id: UUID) -> int: - return await self._repository.mark_all_read(user_id=user_id) + updated_count = await self._repository.mark_all_read(user_id=user_id) + if updated_count > 0: + await self._repository.commit() + return updated_count + + async def link_published_notifications_to_user(self, *, user_id: UUID) -> int: + return await self._repository.link_published_notifications_to_user( + user_id=user_id + ) def _parse_payload(raw: dict[str, object]) -> NotificationPayload: diff --git a/backend/src/v1/router.py b/backend/src/v1/router.py index 00a775a..4ab2e35 100644 --- a/backend/src/v1/router.py +++ b/backend/src/v1/router.py @@ -4,6 +4,7 @@ from fastapi import APIRouter from v1.agent.router import router as agent_router from v1.auth.router import router as auth_router +from v1.invite.router import router as invite_router from v1.notifications.router import router as notifications_router from v1.points.router import router as points_router from v1.users.router import router as users_router @@ -12,6 +13,7 @@ from v1.users.router import router as users_router router = APIRouter(prefix="/api/v1") router.include_router(auth_router) router.include_router(agent_router) +router.include_router(invite_router) router.include_router(notifications_router) router.include_router(points_router) router.include_router(users_router) diff --git a/backend/src/v1/users/repository.py b/backend/src/v1/users/repository.py index 3f3dfa8..61a1a36 100644 --- a/backend/src/v1/users/repository.py +++ b/backend/src/v1/users/repository.py @@ -3,9 +3,11 @@ from __future__ import annotations from dataclasses import dataclass from uuid import UUID -from sqlalchemy import select +from sqlalchemy import delete, or_, select from sqlalchemy.ext.asyncio import AsyncSession +from models.invite_code import InviteCode +from models.points_audit_ledger import PointsAuditLedger from models.profile import Profile @@ -35,3 +37,28 @@ class SQLAlchemyUserRepository: async def save(self) -> None: await self.session.commit() + + async def delete_invite_codes_by_owner_id(self, *, user_id: UUID) -> int: + stmt = delete(InviteCode).where(InviteCode.owner_id == user_id) + result = await self.session.execute(stmt) + return int(result.rowcount or 0) + + async def delete_points_audit_snapshots( + self, + *, + user_id: UUID, + user_email: str | None, + ) -> int: + if user_email: + stmt = delete(PointsAuditLedger).where( + or_( + PointsAuditLedger.user_id_snapshot == user_id, + PointsAuditLedger.user_email_snapshot == user_email, + ) + ) + else: + stmt = delete(PointsAuditLedger).where( + PointsAuditLedger.user_id_snapshot == user_id + ) + result = await self.session.execute(stmt) + return int(result.rowcount or 0) diff --git a/backend/src/v1/users/service.py b/backend/src/v1/users/service.py index 41cbac7..eb969d7 100644 --- a/backend/src/v1/users/service.py +++ b/backend/src/v1/users/service.py @@ -296,7 +296,9 @@ class UserService: user_id = str(self.current_user.id) avatar_bucket = config.storage.avatar.bucket avatar_prefix = f"{self.current_user.id}/" - points_repository = PointsRepository(self.repository.session) + session = self.repository.session + points_repository = PointsRepository(session) if session is not None else None + normalized_email = (self.current_user.email or "").strip().lower() or None try: await self.attachment_storage.delete_prefix( @@ -318,30 +320,51 @@ class UserService: ), ) from exc - try: - user_email = (self.current_user.email or "").strip().lower() - if user_email: - email_hash = PointsService._build_register_bonus_email_hash(user_email) - account = await points_repository.get_user_points( - user_id=self.current_user.id + if session is not None and points_repository is not None: + try: + deleted_invite_codes = ( + await self.repository.delete_invite_codes_by_owner_id( + user_id=self.current_user.id + ) ) - await points_repository.update_register_bonus_balance_snapshot( - email_hash=email_hash, - balance_snapshot=int(account.balance), + deleted_audit_rows = ( + await self.repository.delete_points_audit_snapshots( + user_id=self.current_user.id, + user_email=normalized_email, + ) ) - await self.repository.session.commit() - except Exception as exc: - logger.exception( - "Account deletion failed while persisting points snapshot", - user_id=user_id, - ) - raise ApiProblemError( - status_code=502, - detail=problem_payload( - code="PROFILE_DELETE_FAILED", - detail="Failed to delete account data", - ), - ) from exc + + if normalized_email: + email_hash = PointsService._build_register_bonus_email_hash( + normalized_email + ) + account = await points_repository.get_user_points( + user_id=self.current_user.id + ) + await points_repository.update_register_bonus_balance_snapshot( + email_hash=email_hash, + balance_snapshot=int(account.balance), + ) + + await session.commit() + logger.info( + "Account deletion local data cleanup completed", + user_id=user_id, + invite_codes_deleted=deleted_invite_codes, + points_audit_rows_deleted=deleted_audit_rows, + ) + except Exception as exc: + logger.exception( + "Account deletion failed while cleaning local data", + user_id=user_id, + ) + raise ApiProblemError( + status_code=502, + detail=problem_payload( + code="PROFILE_DELETE_FAILED", + detail="Failed to delete account data", + ), + ) from exc try: await self.attachment_storage.delete_auth_user(user_id=user_id) diff --git a/backend/tests/integration/test_follow_up_flow.py b/backend/tests/integration/test_follow_up_flow.py new file mode 100644 index 0000000..fadac11 --- /dev/null +++ b/backend/tests/integration/test_follow_up_flow.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import json +import time +import uuid +from typing import TypedDict + +import httpx +import pytest + + +class IdentityData(TypedDict): + email: str + code: str + + +async def _create_email_session( + client: httpx.AsyncClient, + *, + email: str, + code: str, +) -> dict[str, object]: + resp = await client.post( + "/api/v1/auth/email-session", + json={"email": email, "token": code}, + ) + resp.raise_for_status() + return resp.json() + + +async def _wait_terminal_event( + client: httpx.AsyncClient, + *, + access_token: str, + thread_id: str, + run_id: str, + timeout_s: int = 180, +) -> str: + headers = {"Authorization": f"Bearer {access_token}"} + params = {"runId": run_id, "idle_limit": 120} + started = time.time() + + async with client.stream( + "GET", + f"/api/v1/agent/runs/{thread_id}/events", + headers=headers, + params=params, + ) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if time.time() - started > timeout_s: + raise TimeoutError("SSE timed out") + if not line or not line.startswith("data: "): + continue + event = json.loads(line[6:]) + event_type = event.get("type") + if event_type in {"RUN_FINISHED", "RUN_ERROR"}: + return str(event_type) + + raise RuntimeError("No terminal SSE event") + + +def _build_run_payload( + *, + thread_id: str, + run_id: str, + runtime_mode: str, + question: str, +) -> dict[str, object]: + now = int(time.time() * 1000) + return { + "threadId": thread_id, + "runId": run_id, + "state": {}, + "messages": [ + { + "id": f"msg_{run_id}_user_0", + "role": "user", + "content": question, + } + ], + "tools": [], + "context": [], + "forwardedProps": { + "runtime_mode": runtime_mode, + "client_time": { + "device_timezone": "Asia/Shanghai", + "client_now_iso": "2026-04-10T12:00:00Z", + "client_epoch_ms": now, + }, + "divinationPayload": { + "divinationMethod": "自动起卦", + "questionType": "运势", + "question": question, + "divinationTimeIso": "2026-04-10T12:00:00Z", + "yaoLines": ["少阳", "少阴", "老阳", "少阳", "老阴", "少阴"], + }, + }, + } + + +@pytest.mark.asyncio +async def test_follow_up_run_succeeds_and_limit_uses_assistant_count( + api_client: httpx.AsyncClient, + test_identity: IdentityData, + db_cleanup: list[str], +) -> None: + email = str(test_identity["email"]).strip().lower() + db_cleanup.append(email) + + login = await _create_email_session( + api_client, + email=email, + code=str(test_identity["code"]), + ) + token = str(login["access_token"]) + headers = {"Authorization": f"Bearer {token}"} + + thread_id = str(uuid.uuid4()) + + first_run_id = f"run_chat_{int(time.time() * 1000)}" + first_enqueue = await api_client.post( + "/api/v1/agent/runs", + headers=headers, + json=_build_run_payload( + thread_id=thread_id, + run_id=first_run_id, + runtime_mode="chat", + question="这周适合推进新项目吗?", + ), + ) + first_enqueue.raise_for_status() + assert first_enqueue.status_code == 202 + + first_terminal = await _wait_terminal_event( + api_client, + access_token=token, + thread_id=thread_id, + run_id=first_run_id, + ) + assert first_terminal == "RUN_FINISHED" + + second_run_id = f"run_follow_up_{int(time.time() * 1000)}" + second_enqueue = await api_client.post( + "/api/v1/agent/runs", + headers=headers, + json=_build_run_payload( + thread_id=thread_id, + run_id=second_run_id, + runtime_mode="follow_up", + question="那我第一步应该先做什么?", + ), + ) + second_enqueue.raise_for_status() + assert second_enqueue.status_code == 202 + + second_terminal = await _wait_terminal_event( + api_client, + access_token=token, + thread_id=thread_id, + run_id=second_run_id, + ) + assert second_terminal == "RUN_FINISHED" + + history_resp = await api_client.get( + "/api/v1/agent/history", + headers=headers, + params={"threadId": thread_id}, + ) + history_resp.raise_for_status() + history_payload = history_resp.json() + messages = history_payload.get("messages") + assert isinstance(messages, list) + assistant_messages = [ + message + for message in messages + if isinstance(message, dict) and message.get("role") == "assistant" + ] + assert len(assistant_messages) == 2 + + third_run_id = f"run_follow_up_blocked_{int(time.time() * 1000)}" + third_enqueue = await api_client.post( + "/api/v1/agent/runs", + headers=headers, + json=_build_run_payload( + thread_id=thread_id, + run_id=third_run_id, + runtime_mode="follow_up", + question="还有哪些风险要特别注意?", + ), + ) + assert third_enqueue.status_code == 409 + error_payload = third_enqueue.json() + assert error_payload.get("code") == "AGENT_SESSION_RUN_LIMIT_EXCEEDED" + params = error_payload.get("params") + assert isinstance(params, dict) + assert params.get("maxRuns") == 2 diff --git a/backend/tests/unit/test_notification_service.py b/backend/tests/unit/test_notification_service.py index 4fc5e6a..d16410d 100644 --- a/backend/tests/unit/test_notification_service.py +++ b/backend/tests/unit/test_notification_service.py @@ -61,6 +61,7 @@ class _FakeNotificationRepository: self._items: list[tuple[_FakeUserNotification, _FakeNotification]] = [] self._mark_read_ids: list[UUID] = [] self._mark_all_read_user_ids: list[UUID] = [] + self._commit_count = 0 def add_item(self, un: _FakeUserNotification, n: _FakeNotification) -> None: self._items.append((un, n)) @@ -129,6 +130,9 @@ class _FakeNotificationRepository: count += 1 return count + async def commit(self) -> None: + self._commit_count += 1 + @pytest.fixture def fake_repo() -> _FakeNotificationRepository: diff --git a/backend/tests/unit/test_runtime_context_messages.py b/backend/tests/unit/test_runtime_context_messages.py new file mode 100644 index 0000000..8530197 --- /dev/null +++ b/backend/tests/unit/test_runtime_context_messages.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from core.agentscope.runtime.tasks import _build_recent_context_messages +from schemas.agent.forwarded_props import RuntimeMode +from schemas.agent.runtime_config import MessageContextConfig + + +class _StubContextCache: + def __init__(self, messages: list[dict[str, object]]) -> None: + self._messages = messages + + async def get(self, **_: object) -> list[dict[str, object]]: + return self._messages + + +class _StubAttachmentCache: + async def get(self, **_: object) -> None: + return None + + async def set(self, **_: object) -> None: + return None + + +@pytest.mark.asyncio +async def test_build_recent_context_messages_accepts_snake_case_ganzhi( + monkeypatch: pytest.MonkeyPatch, +) -> None: + metadata_payload: dict[str, Any] = { + "run_id": "run_1", + "agent_output": { + "status": "success", + "sign_level": "中上签", + "conclusion": ["结论"], + "focus_points": ["重点"], + "advice": ["建议"], + "keywords": ["一", "二", "三"], + "answer": "这是回答", + "divination_derived": { + "question": "问题", + "question_type": "运势", + "divination_method": "自动起卦", + "divination_time": "2026-04-10T12:00:00Z", + "binary_code": "101010", + "changed_binary_code": "010101", + "gua_name": "乾为天", + "upper_name": "乾", + "lower_name": "乾", + "target_gua_name": "坤为地", + "world_position": 3, + "response_position": 6, + "has_changing_yao": True, + "ganzhi": { + "year_gan_zhi": "甲子", + "month_gan_zhi": "乙丑", + "day_gan_zhi": "丙寅", + "time_gan_zhi": "丁卯", + "year_kong_wang": "戌亥", + "month_kong_wang": "申酉", + "day_kong_wang": "午未", + "time_kong_wang": "辰巳", + "yue_jian": "子月", + "ri_chen": "寅日", + "yue_po": "午火", + "ri_chong": "申金", + }, + "wu_xing_statuses": {"金": "旺"}, + "yao_info_list": [ + { + "position": 1, + "spirit_name": "青龙", + "relation_name": "兄弟", + "tigan_name": "甲", + "element_name": "木", + "is_yang": True, + "is_changing": False, + "special_mark": "", + }, + { + "position": 2, + "spirit_name": "朱雀", + "relation_name": "子孙", + "tigan_name": "乙", + "element_name": "火", + "is_yang": False, + "is_changing": False, + "special_mark": "", + }, + { + "position": 3, + "spirit_name": "勾陈", + "relation_name": "妻财", + "tigan_name": "丙", + "element_name": "土", + "is_yang": True, + "is_changing": True, + "special_mark": "世", + }, + { + "position": 4, + "spirit_name": "腾蛇", + "relation_name": "官鬼", + "tigan_name": "丁", + "element_name": "金", + "is_yang": False, + "is_changing": False, + "special_mark": "", + }, + { + "position": 5, + "spirit_name": "白虎", + "relation_name": "父母", + "tigan_name": "戊", + "element_name": "水", + "is_yang": True, + "is_changing": False, + "special_mark": "应", + }, + { + "position": 6, + "spirit_name": "玄武", + "relation_name": "兄弟", + "tigan_name": "己", + "element_name": "木", + "is_yang": False, + "is_changing": True, + "special_mark": "", + }, + ], + "target_yao_info_list": [], + "fushen_positions": [], + "fushen_info_list": [], + }, + }, + } + + cache = _StubContextCache( + messages=[ + { + "role": "assistant", + "content": "fallback", + "metadata": metadata_payload, + } + ] + ) + monkeypatch.setattr( + "core.agentscope.runtime.tasks.create_context_messages_cache", + lambda: cache, + ) + monkeypatch.setattr( + "core.agentscope.runtime.tasks.create_attachment_content_cache", + lambda: _StubAttachmentCache(), + ) + + converted = await _build_recent_context_messages( + session=None, + thread_id="thread_1", + runtime_mode=RuntimeMode.CHAT, + context_config=MessageContextConfig(), + ) + + assert len(converted) == 1 + content = converted[0].content + assert isinstance(content, str) + assert "[assistant_context]" in content + assert "gua_name:" in content diff --git a/docs/protocols/divination/divination-run-protocol.md b/docs/protocols/divination/divination-run-protocol.md index ee09ac6..bdba8f5 100644 --- a/docs/protocols/divination/divination-run-protocol.md +++ b/docs/protocols/divination/divination-run-protocol.md @@ -91,6 +91,34 @@ Protocol verification status: - `yaoLines` item enum: `少阳 | 少阴 | 老阳 | 老阴` - Additional fields are forbidden. +### Frontend coin-face to `yaoLines` derivation rules + +This section is normative for frontend collection flows (`手动起卦` and `自动起卦`). + +- Both manual and auto flows MUST use the same canonical conversion logic. +- Conversion baseline is manual flow semantics (`huaCount` baseline). +- Auto flow (`ziCount` baseline) MUST be converted to `huaCount` before mapping. +- Do not maintain separate mapping tables per page/screen. + +Canonical mapping (`huaCount` -> `yaoType`): + +- `0` -> `老阴` +- `1` -> `少阳` +- `2` -> `少阴` +- `3` -> `老阳` + +Equivalent auto mapping (`ziCount` -> `yaoType`): + +- `0` -> `老阳` +- `1` -> `少阴` +- `2` -> `少阳` +- `3` -> `老阴` + +Implementation requirement: + +- Frontend should centralize this conversion in one reusable converter and use it in both manual and auto screens. +- `yaoLines` sent to backend MUST always be derived from this canonical mapping and keep order `初爻 -> 上爻`. + ### `runtime_mode` rules - Allowed values: `chat | follow_up`.