feat: integrate invite API and improve notification handling

- Add invite code display and binding functionality via API
- Fix notification unread count sync on auth state change
- Improve notification mark read with server state validation
- Add auth state listener to trigger notification refresh
- Add YaoCoinConverter for coin-to-yao type conversion
- Remove YaoLegend from divination screens (UI cleanup)
- Abbreviate relation labels in yao detail view
- Add re-register notice to account delete screen
- Update 'coins' terminology to 'points' in localization
- Fix backend points consumption to only run in CHAT mode
- Add HttpxAuthNoiseFilter to suppress auth endpoint logging
- Fix notification static_schema import path
- Add test coverage for notification bloc error handling
- Update AGENTS.md page header rules and image handling
- Delete deprecated run-dev.sh script
This commit is contained in:
qzl
2026-04-13 14:52:22 +08:00
parent da947f9f08
commit 1e22f27de2
52 changed files with 1419 additions and 307 deletions
+1 -32
View File
@@ -44,39 +44,8 @@ When viewing data in the database, use `supabase mcp` tools (`supabase_execute_s
## Image Handling ## 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:START --> <!-- TRELLIS:START -->
# Trellis Instructions # Trellis Instructions
+20
View File
@@ -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:`. - `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. - 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 Terminology (Must)
- Divination domain terminology must use fixed Chinese terms in code contracts, protocol fields, and UI semantic labels. - Divination domain terminology must use fixed Chinese terms in code contracts, protocol fields, and UI semantic labels.
+16 -1
View File
@@ -53,6 +53,7 @@ class _EryaoAppState extends State<EryaoApp> {
List<DivinationResultData> _historyRecords = const <DivinationResultData>[]; List<DivinationResultData> _historyRecords = const <DivinationResultData>[];
bool _loadingProfile = false; bool _loadingProfile = false;
String? _loadedProfileUserEmail; String? _loadedProfileUserEmail;
String? _lastUnreadRefreshedUserId;
@override @override
void initState() { void initState() {
@@ -77,9 +78,23 @@ class _EryaoAppState extends State<EryaoApp> {
sessionStore: _sessionStore, sessionStore: _sessionStore,
); );
_authBloc = AuthBloc(repository: authRepository); _authBloc = AuthBloc(repository: authRepository);
_authBloc.addListener(_onAuthStateChanged);
_bootstrap(); _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) { void _ensureCreditsLoaded(String userEmail) {
if (_loadingCredits) { if (_loadingCredits) {
return; return;
@@ -357,6 +372,7 @@ class _EryaoAppState extends State<EryaoApp> {
@override @override
void dispose() { void dispose() {
_authBloc.removeListener(_onAuthStateChanged);
_authBloc.dispose(); _authBloc.dispose();
_notificationBloc.dispose(); _notificationBloc.dispose();
super.dispose(); super.dispose();
@@ -427,7 +443,6 @@ class _EryaoAppState extends State<EryaoApp> {
_ensureCreditsLoaded(state.user!.email); _ensureCreditsLoaded(state.user!.email);
_ensureHistoryLoaded(state.user!.email); _ensureHistoryLoaded(state.user!.email);
_refreshProfile(userEmail: state.user!.email); _refreshProfile(userEmail: state.user!.email);
_notificationBloc.handleEvent(RefreshUnreadCount());
return HomeScreen( return HomeScreen(
account: state.user!.email, account: state.user!.email,
sessionStore: _sessionStore, sessionStore: _sessionStore,
@@ -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);
}
}
@@ -14,7 +14,6 @@ import '../../../../shared/widgets/app_modal_dialog.dart';
import '../../../../shared/widgets/gua_icon.dart'; import '../../../../shared/widgets/gua_icon.dart';
import '../../../../shared/widgets/divination/divination_shared_widgets.dart'; import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
import '../../../../shared/widgets/divination/divination_terms.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/divination/yao_line_row.dart';
import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart'; import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
import '../../../../shared/widgets/toast/toast.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/apis/divination_api.dart';
import '../../data/models/divination_params.dart'; import '../../data/models/divination_params.dart';
import '../../data/models/divination_result.dart'; import '../../data/models/divination_result.dart';
import '../../data/models/yao_coin_converter.dart';
import '../../data/services/divination_run_service.dart'; import '../../data/services/divination_run_service.dart';
import 'divination_processing_screen.dart'; import 'divination_processing_screen.dart';
@@ -287,14 +287,8 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
final c1 = _random.nextBool(); final c1 = _random.nextBool();
final c2 = _random.nextBool(); final c2 = _random.nextBool();
final c3 = _random.nextBool(); final c3 = _random.nextBool();
final yangCount = [c1, c2, c3].where((v) => v).length; final ziCount = [c1, c2, c3].where((v) => v).length;
final yao = switch (yangCount) { final yao = YaoCoinConverter.fromZiCount(ziCount);
0 => YaoType.oldYin,
1 => YaoType.youngYang,
2 => YaoType.youngYin,
3 => YaoType.oldYang,
_ => YaoType.undetermined,
};
setState(() { setState(() {
_isSpinning = false; _isSpinning = false;
_coin1Yang = c1; _coin1Yang = c1;
@@ -737,7 +731,6 @@ class _HexagramCard extends StatelessWidget {
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
for (int i = 5; i >= 0; i--) _YaoRow(index: i, type: yaoStates[i]), for (int i = 5; i >= 0; i--) _YaoRow(index: i, type: yaoStates[i]),
const SizedBox(height: AppSpacing.xs), const SizedBox(height: AppSpacing.xs),
const Align(alignment: Alignment.centerLeft, child: YaoLegend()),
], ],
), ),
), ),
@@ -10,7 +10,6 @@ import '../../../../shared/theme/app_color_palette.dart';
import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/divination/divination_terms.dart'; import '../../../../shared/widgets/divination/divination_terms.dart';
import '../../../../shared/widgets/divination/yao_glyph.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.dart';
import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/apis/divination_api.dart'; import '../../data/apis/divination_api.dart';
@@ -926,11 +925,6 @@ class _HexagramDetailCard extends StatelessWidget {
showTarget: showTarget:
data.hasChangingYao && idx < data.targetYaoLines.length, 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( SizedBox(
width: 28, width: 28,
child: Text(data.relation, textAlign: TextAlign.center), child: Text(
_abbreviateRelation(data.relation),
textAlign: TextAlign.center,
),
), ),
SizedBox( SizedBox(
width: 18, width: 18,
@@ -1183,4 +1180,15 @@ class _YaoDetailRow extends StatelessWidget {
String _changeMark(YaoType type) { String _changeMark(YaoType type) {
return type.changeMark; return type.changeMark;
} }
String _abbreviateRelation(String relation) {
return switch (relation) {
'子孙' => '',
'妻财' => '',
'官鬼' => '',
'兄弟' => '',
'父母' => '',
_ => relation,
};
}
} }
@@ -11,7 +11,6 @@ import '../../../../shared/widgets/app_modal_dialog.dart';
import '../../../../shared/widgets/gua_icon.dart'; import '../../../../shared/widgets/gua_icon.dart';
import '../../../../shared/widgets/divination/divination_shared_widgets.dart'; import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
import '../../../../shared/widgets/divination/divination_terms.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/divination/yao_line_row.dart';
import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart'; import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
import '../../../../shared/widgets/toast/toast.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/apis/divination_api.dart';
import '../../data/models/divination_params.dart'; import '../../data/models/divination_params.dart';
import '../../data/models/divination_result.dart'; import '../../data/models/divination_result.dart';
import '../../data/models/yao_coin_converter.dart';
import '../../data/services/divination_run_service.dart'; import '../../data/services/divination_run_service.dart';
import 'divination_processing_screen.dart'; import 'divination_processing_screen.dart';
@@ -524,7 +524,6 @@ class _YaoSelectionCard extends StatelessWidget {
); );
}), }),
const SizedBox(height: AppSpacing.xs), const SizedBox(height: AppSpacing.xs),
const Align(alignment: Alignment.centerLeft, child: YaoLegend()),
], ],
), ),
), ),
@@ -565,13 +564,7 @@ class _ThreeCoinSelectorDialogState extends State<_ThreeCoinSelectorDialog> {
YaoType get _currentYaoType { YaoType get _currentYaoType {
final huaCount = _coinStates.where((isHua) => isHua).length; final huaCount = _coinStates.where((isHua) => isHua).length;
return switch (huaCount) { return YaoCoinConverter.fromHuaCount(huaCount);
0 => YaoType.oldYin,
1 => YaoType.youngYang,
2 => YaoType.youngYin,
3 => YaoType.oldYang,
_ => YaoType.undetermined,
};
} }
void _toggleCoin(int index) { void _toggleCoin(int index) {
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../core/auth/session_store.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_screen.dart';
import '../../../divination/presentation/screens/divination_result_screen.dart'; import '../../../divination/presentation/screens/divination_result_screen.dart';
import '../../../divination/data/apis/divination_api.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/data/repositories/notification_repository.dart';
import '../../../notifications/presentation/bloc/notification_bloc.dart'; import '../../../notifications/presentation/bloc/notification_bloc.dart';
import '../../../notifications/presentation/screens/notification_center_screen.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/models/profile_settings.dart';
import '../../../settings/data/repositories/invite_repository.dart';
import '../../../settings/presentation/screens/settings_screen.dart'; import '../../../settings/presentation/screens/settings_screen.dart';
import '../../../../app/di/injection.dart';
import '../../../../l10n/app_localizations.dart'; import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/app_color_palette.dart'; import '../../../../shared/theme/app_color_palette.dart';
import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/theme/design_tokens.dart';
@@ -68,10 +72,18 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> { class _HomeScreenState extends State<HomeScreen> {
MainTab _currentTab = MainTab.home; MainTab _currentTab = MainTab.home;
late final InviteRepository _inviteRepository;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final inviteApi = InviteApi(
apiClient: ApiClient(
baseUrl: appDependencies.backendUrl,
tokenProvider: widget.sessionStore.getToken,
),
);
_inviteRepository = InviteRepositoryImpl(inviteApi: inviteApi);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_tryShowWelcomeDialog(); _tryShowWelcomeDialog();
}); });
@@ -120,6 +132,7 @@ class _HomeScreenState extends State<HomeScreen> {
account: widget.account, account: widget.account,
settings: widget.profileSettings, settings: widget.profileSettings,
coinBalance: widget.coinBalance, coinBalance: widget.coinBalance,
inviteRepository: _inviteRepository,
onLocaleChanged: widget.onLocaleChanged, onLocaleChanged: widget.onLocaleChanged,
onSettingsChanged: widget.onProfileSettingsChanged, onSettingsChanged: widget.onProfileSettingsChanged,
onSaveProfile: widget.onSaveProfile, onSaveProfile: widget.onSaveProfile,
@@ -209,6 +222,11 @@ class _HomeTab extends StatelessWidget {
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (_) => NotificationCenterScreen( builder: (_) => NotificationCenterScreen(
repository: notificationRepository, repository: notificationRepository,
onUnreadCountChanged: () {
return notificationBloc.handleEvent(
RefreshUnreadCount(),
);
},
), ),
), ),
); );
@@ -532,6 +550,7 @@ class _ProfileTab extends StatelessWidget {
required this.account, required this.account,
required this.settings, required this.settings,
required this.coinBalance, required this.coinBalance,
required this.inviteRepository,
required this.onLocaleChanged, required this.onLocaleChanged,
required this.onSettingsChanged, required this.onSettingsChanged,
required this.onSaveProfile, required this.onSaveProfile,
@@ -543,6 +562,7 @@ class _ProfileTab extends StatelessWidget {
final String account; final String account;
final ProfileSettingsV1 settings; final ProfileSettingsV1 settings;
final int coinBalance; final int coinBalance;
final InviteRepository inviteRepository;
final Future<void> Function(String languageTag) onLocaleChanged; final Future<void> Function(String languageTag) onLocaleChanged;
final Future<void> Function(ProfileSettingsV1 settings) onSettingsChanged; final Future<void> Function(ProfileSettingsV1 settings) onSettingsChanged;
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated) final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
@@ -557,6 +577,7 @@ class _ProfileTab extends StatelessWidget {
account: account, account: account,
settings: settings, settings: settings,
coinBalance: coinBalance, coinBalance: coinBalance,
inviteRepository: inviteRepository,
onInterfaceLanguageChanged: onLocaleChanged, onInterfaceLanguageChanged: onLocaleChanged,
onSettingsChanged: onSettingsChanged, onSettingsChanged: onSettingsChanged,
onSaveProfile: onSaveProfile, onSaveProfile: onSaveProfile,
@@ -60,11 +60,20 @@ class NotificationApi {
} }
Future<NotificationItem> markRead({required String notificationId}) async { Future<NotificationItem> markRead({required String notificationId}) async {
_logger.info(
message: 'Mark read request started',
extra: {'notification_id': notificationId},
);
try { try {
final response = await _apiClient.rawDio.patch<Map<String, dynamic>>( final response = await _apiClient.rawDio.patch<Map<String, dynamic>>(
'/api/v1/notifications/$notificationId/read', '/api/v1/notifications/$notificationId/read',
); );
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) { } on DioException catch (error, stackTrace) {
_logger.error( _logger.error(
message: 'Mark read failed', message: 'Mark read failed',
@@ -76,11 +85,17 @@ class NotificationApi {
} }
Future<int> markAllRead() async { Future<int> markAllRead() async {
_logger.info(message: 'Mark all read request started');
try { try {
final response = await _apiClient.rawDio.patch<Map<String, dynamic>>( final response = await _apiClient.rawDio.patch<Map<String, dynamic>>(
'/api/v1/notifications/mark-all-read', '/api/v1/notifications/mark-all-read',
); );
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) { } on DioException catch (error, stackTrace) {
_logger.error( _logger.error(
message: 'Mark all read failed', message: 'Mark all read failed',
@@ -185,58 +185,64 @@ class NotificationBloc extends ChangeNotifier {
} }
Future<void> _markRead(String notificationId) async { Future<void> _markRead(String notificationId) async {
final previousItems = _state.items;
final previousCount = _state.unreadCount;
final idx = _state.items.indexWhere((item) => item.id == notificationId); final idx = _state.items.indexWhere((item) => item.id == notificationId);
if (idx == -1) return; if (idx == -1) return;
if (_state.items[idx].isRead) return;
final wasUnread = !_state.items[idx].isRead; _logger.info(
_state = _state.copyWith( message: 'Mark notification read started',
items: [ extra: {'notification_id': notificationId},
..._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,
); );
notifyListeners();
try { 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) { } catch (error, stackTrace) {
_logger.error( _logger.error(
message: 'Mark read failed: ${error.runtimeType}', message: 'Mark read failed: ${error.runtimeType}',
error: error, error: error,
stackTrace: stackTrace, stackTrace: stackTrace,
); );
_state = _state.copyWith(
items: previousItems,
unreadCount: previousCount,
);
notifyListeners();
} }
} }
Future<void> _markAllRead() async { Future<void> _markAllRead() async {
final previousItems = _state.items; _logger.info(message: 'Mark all notifications read started');
try {
await _repository.markAllRead();
_state = _state.copyWith( _state = _state.copyWith(
items: _state.items.map((item) => item.copyWith(isRead: true)).toList(), items: _state.items.map((item) => item.copyWith(isRead: true)).toList(),
unreadCount: 0, unreadCount: 0,
); );
notifyListeners(); notifyListeners();
_logger.info(message: 'Mark all notifications read succeeded');
try {
await _repository.markAllRead();
} catch (error, stackTrace) { } catch (error, stackTrace) {
_logger.error( _logger.error(
message: 'Mark all read failed: ${error.runtimeType}', message: 'Mark all read failed: ${error.runtimeType}',
error: error, error: error,
stackTrace: stackTrace, stackTrace: stackTrace,
); );
_state = _state.copyWith(items: previousItems);
notifyListeners();
} }
} }
@@ -1,6 +1,9 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../../shared/theme/design_tokens.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_item.dart';
import '../../data/models/notification_payload.dart'; import '../../data/models/notification_payload.dart';
import '../../data/repositories/notification_repository.dart'; import '../../data/repositories/notification_repository.dart';
@@ -13,12 +16,14 @@ class NotificationCenterScreen extends StatefulWidget {
required this.repository, required this.repository,
this.onNavigateToRoute, this.onNavigateToRoute,
this.onOpenUrl, this.onOpenUrl,
this.onUnreadCountChanged,
}); });
final NotificationRepository repository; final NotificationRepository repository;
final void Function(String route, {String? entityId, String? tab})? final void Function(String route, {String? entityId, String? tab})?
onNavigateToRoute; onNavigateToRoute;
final void Function(String url)? onOpenUrl; final void Function(String url)? onOpenUrl;
final Future<void> Function()? onUnreadCountChanged;
@override @override
State<NotificationCenterScreen> createState() => State<NotificationCenterScreen> createState() =>
@@ -55,6 +60,7 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('通知'), title: const Text('通知'),
centerTitle: true,
actions: [ actions: [
if (state.items.any((item) => !item.isRead)) if (state.items.any((item) => !item.isRead))
TextButton( TextButton(
@@ -136,15 +142,32 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
final item = state.items[index]; final item = state.items[index];
return NotificationListItem( return NotificationListItem(
item: item, item: item,
onTap: () => _handleNotificationTap(item), onTap: () => _handleNotificationTap(context, item),
); );
}, },
); );
} }
void _handleNotificationTap(NotificationItem item) { Future<void> _handleNotificationTap(
BuildContext context,
NotificationItem item,
) async {
final wasUnread = !item.isRead;
if (!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); _executePayload(item.payload);
} }
@@ -161,6 +184,15 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
} }
void _onMarkAllRead() { void _onMarkAllRead() {
_bloc.handleEvent(MarkAllNotificationsRead()); unawaited(_markAllRead());
}
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);
if (unreadBefore && !unreadAfter) {
await widget.onUnreadCountChanged?.call();
}
} }
} }
@@ -18,7 +18,8 @@ class NotificationListItem extends StatelessWidget {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return InkWell( return IntrinsicHeight(
child: InkWell(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@@ -26,7 +27,9 @@ class NotificationListItem extends StatelessWidget {
vertical: AppSpacing.md, vertical: AppSpacing.md,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: item.isRead ? colors.surface : colors.surfaceContainerHighest, color: item.isRead
? colors.surface
: colors.surfaceContainerHighest,
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(
color: colors.outlineVariant.withValues(alpha: 0.3), color: colors.outlineVariant.withValues(alpha: 0.3),
@@ -53,6 +56,7 @@ class NotificationListItem extends StatelessWidget {
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
item.title, item.title,
@@ -87,6 +91,7 @@ class NotificationListItem extends StatelessWidget {
], ],
), ),
), ),
),
); );
} }
@@ -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<MyInviteCode> 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<String, dynamic>) {
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',
);
}
}
@@ -0,0 +1,6 @@
class MyInviteCode {
const MyInviteCode({required this.code, required this.usedCount});
final String code;
final int usedCount;
}
@@ -0,0 +1,17 @@
import '../apis/invite_api.dart';
import '../models/my_invite_code.dart';
abstract class InviteRepository {
Future<MyInviteCode> getMyInviteCode();
}
class InviteRepositoryImpl implements InviteRepository {
InviteRepositoryImpl({required InviteApi inviteApi}) : _inviteApi = inviteApi;
final InviteApi _inviteApi;
@override
Future<MyInviteCode> getMyInviteCode() {
return _inviteApi.getMyInviteCode();
}
}
@@ -208,6 +208,24 @@ class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> {
height: 1.45, 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), const SizedBox(height: AppSpacing.md),
Text( Text(
_secondsLeft > 0 _secondsLeft > 0
@@ -1,44 +1,125 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../../../core/logging/logger.dart';
import '../../../../l10n/app_localizations.dart'; import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/app_color_palette.dart'; import '../../../../shared/theme/app_color_palette.dart';
import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/repositories/invite_repository.dart';
class InviteScreen extends StatefulWidget { class InviteScreen extends StatefulWidget {
const InviteScreen({super.key}); const InviteScreen({super.key, required this.inviteRepository});
final InviteRepository inviteRepository;
@override @override
State<InviteScreen> createState() => _InviteScreenState(); State<InviteScreen> createState() => _InviteScreenState();
} }
class _InviteScreenState extends State<InviteScreen> { class _InviteScreenState extends State<InviteScreen> {
final Logger _logger = getLogger('features.settings.invite_screen');
final _bindCodeController = TextEditingController(); final _bindCodeController = TextEditingController();
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
bool _isBinding = false; bool _isBinding = false;
bool _isGenerating = false; bool _isGenerating = false;
bool _isLoading = true;
bool _hasError = false;
// Mock data - will be replaced with API calls String? _myInviteCode;
final String _myInviteCode = 'ABC123'; int _invitedCount = 0;
final int _invitedCount = 3;
final bool _hasInviter = false; final bool _hasInviter = false;
@override
void initState() {
super.initState();
_loadInviteCode();
}
Future<void> _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 @override
void dispose() { void dispose() {
_bindCodeController.dispose(); _bindCodeController.dispose();
super.dispose(); super.dispose();
} }
bool get _hasMyInviteCode => _myInviteCode.isNotEmpty; bool get _hasMyInviteCode =>
_myInviteCode != null && _myInviteCode!.isNotEmpty;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final colors = Theme.of(context).colorScheme; 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( return Scaffold(
backgroundColor: colors.surfaceContainerLow, backgroundColor: colors.surfaceContainerLow,
appBar: AppBar( appBar: AppBar(
@@ -51,7 +132,10 @@ class _InviteScreenState extends State<InviteScreen> {
padding: const EdgeInsets.all(AppSpacing.lg), padding: const EdgeInsets.all(AppSpacing.lg),
children: [ children: [
if (_hasMyInviteCode) ...[ if (_hasMyInviteCode) ...[
_InviteCodeCard(inviteCode: _myInviteCode, onCopy: _copyInviteCode), _InviteCodeCard(
inviteCode: _myInviteCode!,
onCopy: _copyInviteCode,
),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
_InviteStatsCard(count: _invitedCount), _InviteStatsCard(count: _invitedCount),
const SizedBox(height: AppSpacing.xl), const SizedBox(height: AppSpacing.xl),
@@ -79,7 +163,7 @@ class _InviteScreenState extends State<InviteScreen> {
void _copyInviteCode() { void _copyInviteCode() {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
Clipboard.setData(ClipboardData(text: _myInviteCode)); Clipboard.setData(ClipboardData(text: _myInviteCode!));
Toast.show( Toast.show(
context, context,
l10n.settingsInviteCopySuccess, l10n.settingsInviteCopySuccess,
@@ -5,6 +5,7 @@ import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/app_modal_dialog.dart'; import '../../../../shared/widgets/app_modal_dialog.dart';
import '../../../../shared/widgets/gua_icon.dart'; import '../../../../shared/widgets/gua_icon.dart';
import '../../data/models/profile_settings.dart'; import '../../data/models/profile_settings.dart';
import '../../data/repositories/invite_repository.dart';
import 'account_delete_screen.dart'; import 'account_delete_screen.dart';
import '../widgets/settings_section_widgets.dart'; import '../widgets/settings_section_widgets.dart';
import 'coin_center_screen.dart'; import 'coin_center_screen.dart';
@@ -19,6 +20,7 @@ class SettingsScreen extends StatefulWidget {
required this.account, required this.account,
required this.settings, required this.settings,
required this.coinBalance, required this.coinBalance,
required this.inviteRepository,
required this.onInterfaceLanguageChanged, required this.onInterfaceLanguageChanged,
required this.onSettingsChanged, required this.onSettingsChanged,
required this.onUploadAvatar, required this.onUploadAvatar,
@@ -30,6 +32,7 @@ class SettingsScreen extends StatefulWidget {
final String account; final String account;
final ProfileSettingsV1 settings; final ProfileSettingsV1 settings;
final int coinBalance; final int coinBalance;
final InviteRepository inviteRepository;
final Future<void> Function(String languageTag) onInterfaceLanguageChanged; final Future<void> Function(String languageTag) onInterfaceLanguageChanged;
final Future<void> Function(ProfileSettingsV1 settings) onSettingsChanged; final Future<void> Function(ProfileSettingsV1 settings) onSettingsChanged;
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar; final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
@@ -179,9 +182,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
Future<void> _openInvite() async { Future<void> _openInvite() async {
await Navigator.of( await Navigator.of(context).push<void>(
context, MaterialPageRoute<void>(
).push<void>(MaterialPageRoute<void>(builder: (_) => const InviteScreen())); builder: (_) => InviteScreen(inviteRepository: widget.inviteRepository),
),
);
} }
Future<void> _openProfileEdit() async { Future<void> _openProfileEdit() async {
+2 -1
View File
@@ -146,6 +146,7 @@
"settingsDeleteAccountSubtitle": "Permanently delete your account and personal data", "settingsDeleteAccountSubtitle": "Permanently delete your account and personal data",
"settingsDeleteAccountWarningTitle": "Please confirm before deleting", "settingsDeleteAccountWarningTitle": "Please confirm before deleting",
"settingsDeleteAccountWarningBody": "After deletion, related data including profile, history, and points will be permanently removed and cannot be restored.", "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", "settingsDeleteAccountScopeProfile": "Profile and account information will be deleted",
"settingsDeleteAccountScopeHistory": "Divination history records will be deleted", "settingsDeleteAccountScopeHistory": "Divination history records will be deleted",
"settingsDeleteAccountScopePoints": "Points account and ledger records will be deleted", "settingsDeleteAccountScopePoints": "Points account and ledger records will be deleted",
@@ -295,7 +296,7 @@
"questionTypeSearch": "Search", "questionTypeSearch": "Search",
"questionTypeOther": "Other", "questionTypeOther": "Other",
"toastPleaseInputQuestion": "Please enter your question", "toastPleaseInputQuestion": "Please enter your question",
"toastCoinInsufficient": "Insufficient coins", "toastCoinInsufficient": "Insufficient points",
"divinationCostDialogTitle": "Confirm divination", "divinationCostDialogTitle": "Confirm divination",
"divinationCostDialogBody": "This run costs {cost} credits. Available balance: {balance} credits. Continue?", "divinationCostDialogBody": "This run costs {cost} credits. Available balance: {balance} credits. Continue?",
"@divinationCostDialogBody": { "@divinationCostDialogBody": {
+7 -1
View File
@@ -758,6 +758,12 @@ abstract class AppLocalizations {
/// **'删除账号后,个人资料、历史记录、点数信息等相关数据将被永久删除,且不可恢复。'** /// **'删除账号后,个人资料、历史记录、点数信息等相关数据将被永久删除,且不可恢复。'**
String get settingsDeleteAccountWarningBody; String get settingsDeleteAccountWarningBody;
/// No description provided for @settingsDeleteAccountReRegisterNotice.
///
/// In zh, this message translates to:
/// **'重要提示:同一邮箱删除后重新注册,已消耗积分不会重置或返还。'**
String get settingsDeleteAccountReRegisterNotice;
/// No description provided for @settingsDeleteAccountScopeProfile. /// No description provided for @settingsDeleteAccountScopeProfile.
/// ///
/// In zh, this message translates to: /// In zh, this message translates to:
@@ -1487,7 +1493,7 @@ abstract class AppLocalizations {
/// No description provided for @toastCoinInsufficient. /// No description provided for @toastCoinInsufficient.
/// ///
/// In zh, this message translates to: /// In zh, this message translates to:
/// **'铜钱不足,无法解卦'** /// **'积分不足,无法解卦'**
String get toastCoinInsufficient; String get toastCoinInsufficient;
/// No description provided for @divinationCostDialogTitle. /// No description provided for @divinationCostDialogTitle.
+5 -1
View File
@@ -367,6 +367,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get settingsDeleteAccountWarningBody => String get settingsDeleteAccountWarningBody =>
'After deletion, related data including profile, history, and points will be permanently removed and cannot be restored.'; '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 @override
String get settingsDeleteAccountScopeProfile => String get settingsDeleteAccountScopeProfile =>
'Profile and account information will be deleted'; 'Profile and account information will be deleted';
@@ -770,7 +774,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get toastPleaseInputQuestion => 'Please enter your question'; String get toastPleaseInputQuestion => 'Please enter your question';
@override @override
String get toastCoinInsufficient => 'Insufficient coins'; String get toastCoinInsufficient => 'Insufficient points';
@override @override
String get divinationCostDialogTitle => 'Confirm divination'; String get divinationCostDialogTitle => 'Confirm divination';
+5 -1
View File
@@ -359,6 +359,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get settingsDeleteAccountWarningBody => String get settingsDeleteAccountWarningBody =>
'删除账号后,个人资料、历史记录、点数信息等相关数据将被永久删除,且不可恢复。'; '删除账号后,个人资料、历史记录、点数信息等相关数据将被永久删除,且不可恢复。';
@override
String get settingsDeleteAccountReRegisterNotice =>
'重要提示:同一邮箱删除后重新注册,已消耗积分不会重置或返还。';
@override @override
String get settingsDeleteAccountScopeProfile => '个人资料和账号信息会被删除'; String get settingsDeleteAccountScopeProfile => '个人资料和账号信息会被删除';
@@ -737,7 +741,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get toastPleaseInputQuestion => '请输入您想占卜的问题'; String get toastPleaseInputQuestion => '请输入您想占卜的问题';
@override @override
String get toastCoinInsufficient => '铜钱不足,无法解卦'; String get toastCoinInsufficient => '积分不足,无法解卦';
@override @override
String get divinationCostDialogTitle => '确认开始解卦'; String get divinationCostDialogTitle => '确认开始解卦';
+2 -1
View File
@@ -146,6 +146,7 @@
"settingsDeleteAccountSubtitle": "永久删除账号及相关个人数据", "settingsDeleteAccountSubtitle": "永久删除账号及相关个人数据",
"settingsDeleteAccountWarningTitle": "删除前请确认", "settingsDeleteAccountWarningTitle": "删除前请确认",
"settingsDeleteAccountWarningBody": "删除账号后,个人资料、历史记录、点数信息等相关数据将被永久删除,且不可恢复。", "settingsDeleteAccountWarningBody": "删除账号后,个人资料、历史记录、点数信息等相关数据将被永久删除,且不可恢复。",
"settingsDeleteAccountReRegisterNotice": "重要提示:同一邮箱删除后重新注册,已消耗积分不会重置或返还。",
"settingsDeleteAccountScopeProfile": "个人资料和账号信息会被删除", "settingsDeleteAccountScopeProfile": "个人资料和账号信息会被删除",
"settingsDeleteAccountScopeHistory": "历史解卦记录会被删除", "settingsDeleteAccountScopeHistory": "历史解卦记录会被删除",
"settingsDeleteAccountScopePoints": "点数账户与流水记录会被删除", "settingsDeleteAccountScopePoints": "点数账户与流水记录会被删除",
@@ -295,7 +296,7 @@
"questionTypeSearch": "寻物", "questionTypeSearch": "寻物",
"questionTypeOther": "其他", "questionTypeOther": "其他",
"toastPleaseInputQuestion": "请输入您想占卜的问题", "toastPleaseInputQuestion": "请输入您想占卜的问题",
"toastCoinInsufficient": "铜钱不足,无法解卦", "toastCoinInsufficient": "积分不足,无法解卦",
"divinationCostDialogTitle": "确认开始解卦", "divinationCostDialogTitle": "确认开始解卦",
"divinationCostDialogBody": "本次解卦将消耗 {cost} 点数,当前可用 {balance} 点数。是否继续?", "divinationCostDialogBody": "本次解卦将消耗 {cost} 点数,当前可用 {balance} 点数。是否继续?",
"@divinationCostDialogBody": { "@divinationCostDialogBody": {
@@ -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<void> Function() onMarkRead;
@override
State<NotificationDetailBottomSheet> createState() =>
_NotificationDetailBottomSheetState();
}
class _NotificationDetailBottomSheetState
extends State<NotificationDetailBottomSheet> {
@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<void> showNotificationDetailBottomSheet({
required BuildContext context,
required NotificationItem item,
required Future<void> Function() onMarkRead,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) =>
NotificationDetailBottomSheet(item: item, onMarkRead: onMarkRead),
);
}
@@ -10,6 +10,8 @@ class _FakeNotificationRepository implements NotificationRepository {
final List<NotificationItem> items = []; final List<NotificationItem> items = [];
int unreadCount = 0; int unreadCount = 0;
int markAllReadCallCount = 0; int markAllReadCallCount = 0;
bool failMarkRead = false;
bool failMarkAllRead = false;
@override @override
Future<NotificationListResult> listNotifications({ Future<NotificationListResult> listNotifications({
@@ -28,6 +30,9 @@ class _FakeNotificationRepository implements NotificationRepository {
@override @override
Future<NotificationItem> markRead({required String notificationId}) async { Future<NotificationItem> markRead({required String notificationId}) async {
if (failMarkRead) {
throw Exception('Mark read failed');
}
final idx = items.indexWhere((i) => i.id == notificationId); final idx = items.indexWhere((i) => i.id == notificationId);
if (idx == -1) { if (idx == -1) {
throw Exception('Not found'); throw Exception('Not found');
@@ -39,6 +44,9 @@ class _FakeNotificationRepository implements NotificationRepository {
@override @override
Future<int> markAllRead() async { Future<int> markAllRead() async {
if (failMarkAllRead) {
throw Exception('Mark all read failed');
}
markAllReadCallCount++; markAllReadCallCount++;
final count = unreadCount; final count = unreadCount;
for (int i = 0; i < items.length; i++) { for (int i = 0; i < items.length; i++) {
@@ -99,6 +107,21 @@ void main() {
expect(bloc.state.unreadCount, 0); 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 { test('MarkAllNotificationsRead marks all as read', () async {
fakeRepo.items.addAll([ fakeRepo.items.addAll([
makeItem(id: 'n1', isRead: false), makeItem(id: 'n1', isRead: false),
@@ -112,6 +135,24 @@ void main() {
expect(bloc.state.items.every((i) => i.isRead), true); 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( test(
'NotificationCreatedEvent adds item and increments unreadCount', 'NotificationCreatedEvent adds item and increments unreadCount',
() async { () async {
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BACKEND_URL=""
DEVICE_ARGS=()
usage() {
cat <<EOF
Usage:
$0 [--backend-url http://host:port] [flutter run args...]
Examples:
$0
$0 --backend-url http://192.168.1.100:5775
$0 --backend-url http://10.0.2.2:5775 -d emulator-5554
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--backend-url)
BACKEND_URL="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
DEVICE_ARGS+=("$1")
shift
;;
esac
done
cd "$ROOT_DIR"
if [[ -n "$BACKEND_URL" ]]; then
flutter run --dart-define="BACKEND_URL=$BACKEND_URL" "${DEVICE_ARGS[@]}"
else
flutter run "${DEVICE_ARGS[@]}"
fi
@@ -384,6 +384,7 @@ async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]:
runtime_config=runtime_config, runtime_config=runtime_config,
cancel_checker=_cancel_checker, cancel_checker=_cancel_checker,
) )
if runtime_mode == RuntimeMode.CHAT:
await points_service.consume_successful_run_points( await points_service.consume_successful_run_points(
user_id=owner_id, user_id=owner_id,
session_id=UUID(thread_id), session_id=UUID(thread_id),
@@ -9,7 +9,7 @@ from uuid import UUID
import yaml import yaml
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
from v1.notifications.schemas import ( from backend.src.schemas.shared.notification import (
NotificationPayload, NotificationPayload,
NotificationPayloadNone, NotificationPayloadNone,
) )
+5 -1
View File
@@ -39,6 +39,7 @@ def build_logging_config(runtime: RuntimeSettings) -> dict[str, object]:
file_path=log_dir / runtime.log_file_name, file_path=log_dir / runtime.log_file_name,
level=runtime.log_level, level=runtime.log_level,
formatter=formatter_name, formatter=formatter_name,
filters=["suppress_httpx_auth_noise"],
) )
error_handler = build_file_handler_config( error_handler = build_file_handler_config(
runtime, runtime,
@@ -54,7 +55,10 @@ def build_logging_config(runtime: RuntimeSettings) -> dict[str, object]:
"filters": { "filters": {
"error_only": { "error_only": {
"()": "core.logging.filters.ErrorLevelFilter", "()": "core.logging.filters.ErrorLevelFilter",
} },
"suppress_httpx_auth_noise": {
"()": "core.logging.filters.HttpxAuthNoiseFilter",
},
}, },
"formatters": { "formatters": {
"json": { "json": {
+13
View File
@@ -54,3 +54,16 @@ def build_sensitive_data_processor(
class ErrorLevelFilter(logging.Filter): class ErrorLevelFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool: def filter(self, record: logging.LogRecord) -> bool:
return record.levelno >= logging.ERROR 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)
+3 -3
View File
@@ -25,7 +25,7 @@ class SpecialMark(str, Enum):
class YaoDetail(BaseModel): class YaoDetail(BaseModel):
model_config = ConfigDict(extra="forbid") model_config = ConfigDict(extra="forbid", populate_by_name=True)
position: int = Field(ge=1, le=6) position: int = Field(ge=1, le=6)
spirit_name: str = Field(alias="spiritName", min_length=1) spirit_name: str = Field(alias="spiritName", min_length=1)
@@ -38,7 +38,7 @@ class YaoDetail(BaseModel):
class FushenDetail(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) position: int = Field(ge=1, le=6)
relation_name: str = Field(alias="relationName", min_length=1) relation_name: str = Field(alias="relationName", min_length=1)
@@ -47,7 +47,7 @@ class FushenDetail(BaseModel):
class GanzhiDetail(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) 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) month_gan_zhi: str = Field(alias="monthGanZhi", min_length=2, max_length=2)
@@ -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,
]
+28 -16
View File
@@ -170,7 +170,7 @@ class AgentRepository:
session_row.last_activity_at = datetime.now(timezone.utc) session_row.last_activity_at = datetime.now(timezone.utc)
await self._session.flush() 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: try:
session_uuid = UUID(session_id) session_uuid = UUID(session_id)
except ValueError as exc: except ValueError as exc:
@@ -184,7 +184,7 @@ class AgentRepository:
select(func.count(AgentChatMessage.id)) select(func.count(AgentChatMessage.id))
.where(AgentChatMessage.session_id == session_uuid) .where(AgentChatMessage.session_id == session_uuid)
.where(AgentChatMessage.deleted_at.is_(None)) .where(AgentChatMessage.deleted_at.is_(None))
.where(AgentChatMessage.role == AgentChatMessageRole.USER) .where(AgentChatMessage.role == AgentChatMessageRole.ASSISTANT)
) )
count = (await self._session.execute(stmt)).scalar_one() count = (await self._session.execute(stmt)).scalar_one()
return int(count) return int(count)
@@ -266,7 +266,11 @@ class AgentRepository:
).scalar_one_or_none() is not None ).scalar_one_or_none() is not None
snapshot_messages: list[dict[str, object]] = [] snapshot_messages: list[dict[str, object]] = []
for message in messages: 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 { return {
"day": target_day.isoformat(), "day": target_day.isoformat(),
"hasMore": has_more, "hasMore": has_more,
@@ -278,7 +282,7 @@ class AgentRepository:
*, *,
session_id: str, session_id: str,
visibility_mask: int | None = None, visibility_mask: int | None = None,
) -> list[dict[str, object]]: ) -> list[AgentChatMessageSchema]:
try: try:
session_uuid = UUID(session_id) session_uuid = UUID(session_id)
except ValueError as exc: except ValueError as exc:
@@ -299,9 +303,9 @@ class AgentRepository:
visibility_mask=visibility_mask, visibility_mask=visibility_mask,
) )
messages = (await self._session.execute(message_stmt)).scalars().all() messages = (await self._session.execute(message_stmt)).scalars().all()
snapshot_messages: list[dict[str, object]] = [] snapshot_messages: list[AgentChatMessageSchema] = []
for message in messages: 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 return snapshot_messages
async def get_recent_messages_by_user_window( async def get_recent_messages_by_user_window(
@@ -352,7 +356,11 @@ class AgentRepository:
selected = list(reversed(selected_desc)) selected = list(reversed(selected_desc))
snapshot_messages: list[dict[str, object]] = [] snapshot_messages: list[dict[str, object]] = []
for message in selected: 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 return snapshot_messages
async def get_latest_session_id_for_user(self, *, user_id: str) -> str | None: async def get_latest_session_id_for_user(self, *, user_id: str) -> str | None:
@@ -382,7 +390,7 @@ class AgentRepository:
user_id: str, user_id: str,
visibility_mask: int | None = None, visibility_mask: int | None = None,
session_limit: int = 50, session_limit: int = 50,
) -> list[dict[str, object]]: ) -> list[AgentChatMessageSchema]:
try: try:
user_uuid = UUID(user_id) user_uuid = UUID(user_id)
except ValueError as exc: except ValueError as exc:
@@ -404,7 +412,7 @@ class AgentRepository:
if not session_ids: if not session_ids:
return [] return []
snapshots: list[dict[str, object]] = [] snapshots: list[AgentChatMessageSchema] = []
for session_id in session_ids: for session_id in session_ids:
message_stmt = ( message_stmt = (
select(AgentChatMessage) select(AgentChatMessage)
@@ -423,10 +431,14 @@ class AgentRepository:
) )
if not candidate_messages: if not candidate_messages:
continue continue
selected_snapshot: dict[str, object] | None = None selected_snapshot: AgentChatMessageSchema | None = None
for message in candidate_messages: for message in candidate_messages:
snapshot = await self._to_snapshot_message(message) snapshot = await self._to_chat_message_schema(message)
metadata = snapshot.get("metadata") metadata = (
snapshot.metadata.model_dump(mode="json", exclude_none=True)
if snapshot.metadata is not None
else None
)
if not isinstance(metadata, dict): if not isinstance(metadata, dict):
continue continue
agent_output = metadata.get("agent_output") agent_output = metadata.get("agent_output")
@@ -440,7 +452,7 @@ class AgentRepository:
snapshots.append(selected_snapshot) snapshots.append(selected_snapshot)
snapshots.sort( snapshots.sort(
key=lambda item: str(item.get("timestamp") or ""), key=lambda item: str(item.timestamp),
reverse=True, reverse=True,
) )
return snapshots return snapshots
@@ -462,9 +474,9 @@ class AgentRepository:
"config": config_payload, "config": config_payload,
} }
async def _to_snapshot_message( async def _to_chat_message_schema(
self, message: AgentChatMessage self, message: AgentChatMessage
) -> dict[str, object]: ) -> AgentChatMessageSchema:
role = ( role = (
message.role.value message.role.value
if isinstance(message.role, AgentChatMessageRole) if isinstance(message.role, AgentChatMessageRole)
@@ -487,7 +499,7 @@ class AgentRepository:
"timestamp": message.created_at.astimezone(timezone.utc).isoformat(), "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( def _apply_visibility_filter(
self, self,
+4 -3
View File
@@ -8,6 +8,7 @@ from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from schemas.agent.runtime_models import ErrorInfo from schemas.agent.runtime_models import ErrorInfo
from schemas.domain.chat_message import AgentChatMessage
from schemas.domain.divination import DerivedDivinationData from schemas.domain.divination import DerivedDivinationData
@@ -37,7 +38,7 @@ class AgentRepositoryLike(Protocol):
*, *,
session_id: str, session_id: str,
visibility_mask: int | None = None, 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: ... async def get_latest_session_id_for_user(self, *, user_id: str) -> str | None: ...
@@ -47,7 +48,7 @@ class AgentRepositoryLike(Protocol):
user_id: str, user_id: str,
visibility_mask: int | None = None, visibility_mask: int | None = None,
session_limit: int = 50, session_limit: int = 50,
) -> list[dict[str, object]]: ... ) -> list[AgentChatMessage]: ...
async def persist_user_message( async def persist_user_message(
self, self,
@@ -58,7 +59,7 @@ class AgentRepositoryLike(Protocol):
visibility_mask: int, visibility_mask: int,
) -> None: ... ) -> 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( async def get_system_agent_config(
self, *, agent_type: str self, *, agent_type: str
+16 -14
View File
@@ -46,7 +46,7 @@ from v1.agent.utils import (
) )
logger = get_logger(__name__) 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: def ensure_session_owner(*, owner_id: str, current_user: CurrentUser) -> None:
@@ -151,6 +151,7 @@ class AgentService:
await self._enforce_run_preconditions( await self._enforce_run_preconditions(
thread_id=thread_id, thread_id=thread_id,
current_user=current_user, current_user=current_user,
runtime_mode=runtime_mode,
) )
except ApiProblemError: except ApiProblemError:
if created: if created:
@@ -247,7 +248,7 @@ class AgentService:
metadata: AgentChatMessageMetadata | None, metadata: AgentChatMessageMetadata | None,
) -> None: ) -> None:
metadata_payload = ( 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) if isinstance(metadata, AgentChatMessageMetadata)
else None else None
) )
@@ -494,19 +495,23 @@ class AgentService:
*, *,
thread_id: str, thread_id: str,
current_user: CurrentUser, current_user: CurrentUser,
runtime_mode: RuntimeMode,
) -> None: ) -> 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 session_id=thread_id
) )
if user_message_count >= MAX_RUNS_PER_SESSION: if assistant_message_count >= MAX_ASSISTANT_MESSAGES_PER_SESSION:
raise ApiProblemError( raise ApiProblemError(
status_code=409, status_code=409,
detail=problem_payload( detail=problem_payload(
code="AGENT_SESSION_RUN_LIMIT_EXCEEDED", code="AGENT_SESSION_RUN_LIMIT_EXCEEDED",
detail="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, thread_id: str,
current_user: CurrentUser, current_user: CurrentUser,
) -> HistorySnapshotResponse: ) -> HistorySnapshotResponse:
from schemas.domain.chat_message import AgentChatMessage
from v1.agent.utils import convert_message_to_history from v1.agent.utils import convert_message_to_history
from v1.agent.schemas import HistoryMessage from v1.agent.schemas import HistoryMessage
@@ -609,11 +613,9 @@ class AgentService:
) )
messages: list[HistoryMessage] = [] messages: list[HistoryMessage] = []
for msg_dict in raw_messages: for msg in raw_messages:
msg = AgentChatMessage.model_validate(msg_dict) if msg.role not in {"user", "assistant"}:
if msg.role == "tool":
continue continue
signed_urls: dict[str, str] = {} signed_urls: dict[str, str] = {}
attachments = extract_user_message_attachments(msg.metadata) attachments = extract_user_message_attachments(msg.metadata)
if self._attachment_storage and attachments: if self._attachment_storage and attachments:
@@ -653,7 +655,6 @@ class AgentService:
current_user: CurrentUser, current_user: CurrentUser,
thread_id: str | None, thread_id: str | None,
) -> HistorySnapshotResponse: ) -> HistorySnapshotResponse:
from schemas.domain.chat_message import AgentChatMessage
from v1.agent.utils import convert_message_to_history from v1.agent.utils import convert_message_to_history
from v1.agent.schemas import HistoryMessage from v1.agent.schemas import HistoryMessage
@@ -675,8 +676,9 @@ class AgentService:
visible_messages = raw_messages[:summary_limit] visible_messages = raw_messages[:summary_limit]
messages: list[HistoryMessage] = [] messages: list[HistoryMessage] = []
for msg_dict in visible_messages: for msg in visible_messages:
msg = AgentChatMessage.model_validate(msg_dict) if msg.role != "assistant":
continue
converted = convert_message_to_history(msg) converted = convert_message_to_history(msg)
messages.append(HistoryMessage.model_validate(converted)) messages.append(HistoryMessage.model_validate(converted))
+13
View File
@@ -7,6 +7,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from core.config.settings import config from core.config.settings import config
from core.db import get_db 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.rate_limit import enforce_rate_limit
from v1.auth.dependencies import get_auth_service from v1.auth.dependencies import get_auth_service
from v1.auth.schemas import ( from v1.auth.schemas import (
@@ -22,6 +25,7 @@ from v1.points.service import PointsService
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
logger = get_logger("v1.auth.router")
@router.post("/otp/send", status_code=204) @router.post("/otp/send", status_code=204)
@@ -73,7 +77,16 @@ async def create_email_session(
user_id=UUID(result.user.id), user_id=UUID(result.user.id),
user_email=result.user.email, 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() await session.commit()
logger.info(
"Linked published notifications for authenticated user",
user_id=result.user.id,
linked_count=linked_count,
)
return result return result
+30
View File
@@ -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
+22
View File
@@ -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()
+24
View File
@@ -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)
+10
View File
@@ -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
+28
View File
@@ -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,
)
@@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from uuid import UUID from uuid import UUID
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -111,3 +112,37 @@ class NotificationRepository:
await self._session.execute(stmt) await self._session.execute(stmt)
await self._session.flush() await self._session.flush()
return count 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()))
+24
View File
@@ -4,6 +4,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from core.logging import get_logger
from core.auth.models import CurrentUser from core.auth.models import CurrentUser
from v1.notifications.dependencies import get_notification_service from v1.notifications.dependencies import get_notification_service
from v1.notifications.schemas import ( from v1.notifications.schemas import (
@@ -16,6 +17,7 @@ from v1.notifications.service import NotificationService
from v1.users.dependencies import get_current_user from v1.users.dependencies import get_current_user
router = APIRouter(prefix="/notifications", tags=["notifications"]) router = APIRouter(prefix="/notifications", tags=["notifications"])
logger = get_logger("v1.notifications.router")
@router.get("", response_model=NotificationListResponse) @router.get("", response_model=NotificationListResponse)
@@ -39,6 +41,13 @@ async def list_notifications(
limit=limit, limit=limit,
cursor=parsed_cursor, 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 = [] items = []
for item in result.items: for item in result.items:
items.append( items.append(
@@ -67,6 +76,11 @@ async def get_unread_count(
current_user: Annotated[CurrentUser, Depends(get_current_user)], current_user: Annotated[CurrentUser, Depends(get_current_user)],
) -> UnreadCountResponse: ) -> UnreadCountResponse:
count = await service.get_unread_count(user_id=current_user.id) 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) return UnreadCountResponse(count=count)
@@ -95,6 +109,11 @@ async def mark_notification_read(
user_notification_id=uid, user_notification_id=uid,
user_id=current_user.id, user_id=current_user.id,
) )
logger.info(
"Notification marked as read",
user_id=str(current_user.id),
user_notification_id=str(uid),
)
return NotificationItemResponse( return NotificationItemResponse(
id=str(item.id), id=str(item.id),
notificationId=str(item.notification_id), notificationId=str(item.notification_id),
@@ -114,4 +133,9 @@ async def mark_all_read(
current_user: Annotated[CurrentUser, Depends(get_current_user)], current_user: Annotated[CurrentUser, Depends(get_current_user)],
) -> MarkAllReadResponse: ) -> MarkAllReadResponse:
updated_count = await service.mark_all_read(user_id=current_user.id) 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) return MarkAllReadResponse(updatedCount=updated_count)
+11 -25
View File
@@ -1,35 +1,21 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime
from typing import Literal, Union
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from schemas.shared.notification import (
NotificationPayload,
NotificationPayloadNone,
NotificationPayloadRoute,
NotificationPayloadUrl,
)
class NotificationPayloadNone(BaseModel): __all__ = [
model_config = ConfigDict(extra="forbid") "NotificationPayload",
"NotificationPayloadNone",
action: Literal["none"] "NotificationPayloadRoute",
"NotificationPayloadUrl",
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
] ]
+10 -1
View File
@@ -103,6 +103,7 @@ class NotificationService:
user_notification_id=user_notification_id, user_notification_id=user_notification_id,
user_id=user_id, user_id=user_id,
) )
await self._repository.commit()
payload = _parse_payload(n.payload) payload = _parse_payload(n.payload)
return NotificationListItem( return NotificationListItem(
id=un.id, id=un.id,
@@ -117,7 +118,15 @@ class NotificationService:
) )
async def mark_all_read(self, *, user_id: UUID) -> int: 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: def _parse_payload(raw: dict[str, object]) -> NotificationPayload:
+2
View File
@@ -4,6 +4,7 @@ from fastapi import APIRouter
from v1.agent.router import router as agent_router from v1.agent.router import router as agent_router
from v1.auth.router import router as auth_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.notifications.router import router as notifications_router
from v1.points.router import router as points_router from v1.points.router import router as points_router
from v1.users.router import router as users_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 = APIRouter(prefix="/api/v1")
router.include_router(auth_router) router.include_router(auth_router)
router.include_router(agent_router) router.include_router(agent_router)
router.include_router(invite_router)
router.include_router(notifications_router) router.include_router(notifications_router)
router.include_router(points_router) router.include_router(points_router)
router.include_router(users_router) router.include_router(users_router)
+28 -1
View File
@@ -3,9 +3,11 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from uuid import UUID from uuid import UUID
from sqlalchemy import select from sqlalchemy import delete, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.invite_code import InviteCode
from models.points_audit_ledger import PointsAuditLedger
from models.profile import Profile from models.profile import Profile
@@ -35,3 +37,28 @@ class SQLAlchemyUserRepository:
async def save(self) -> None: async def save(self) -> None:
await self.session.commit() 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)
+29 -6
View File
@@ -296,7 +296,9 @@ class UserService:
user_id = str(self.current_user.id) user_id = str(self.current_user.id)
avatar_bucket = config.storage.avatar.bucket avatar_bucket = config.storage.avatar.bucket
avatar_prefix = f"{self.current_user.id}/" 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: try:
await self.attachment_storage.delete_prefix( await self.attachment_storage.delete_prefix(
@@ -318,10 +320,24 @@ class UserService:
), ),
) from exc ) from exc
if session is not None and points_repository is not None:
try: try:
user_email = (self.current_user.email or "").strip().lower() deleted_invite_codes = (
if user_email: await self.repository.delete_invite_codes_by_owner_id(
email_hash = PointsService._build_register_bonus_email_hash(user_email) user_id=self.current_user.id
)
)
deleted_audit_rows = (
await self.repository.delete_points_audit_snapshots(
user_id=self.current_user.id,
user_email=normalized_email,
)
)
if normalized_email:
email_hash = PointsService._build_register_bonus_email_hash(
normalized_email
)
account = await points_repository.get_user_points( account = await points_repository.get_user_points(
user_id=self.current_user.id user_id=self.current_user.id
) )
@@ -329,10 +345,17 @@ class UserService:
email_hash=email_hash, email_hash=email_hash,
balance_snapshot=int(account.balance), balance_snapshot=int(account.balance),
) )
await self.repository.session.commit()
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: except Exception as exc:
logger.exception( logger.exception(
"Account deletion failed while persisting points snapshot", "Account deletion failed while cleaning local data",
user_id=user_id, user_id=user_id,
) )
raise ApiProblemError( raise ApiProblemError(
@@ -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
@@ -61,6 +61,7 @@ class _FakeNotificationRepository:
self._items: list[tuple[_FakeUserNotification, _FakeNotification]] = [] self._items: list[tuple[_FakeUserNotification, _FakeNotification]] = []
self._mark_read_ids: list[UUID] = [] self._mark_read_ids: list[UUID] = []
self._mark_all_read_user_ids: list[UUID] = [] self._mark_all_read_user_ids: list[UUID] = []
self._commit_count = 0
def add_item(self, un: _FakeUserNotification, n: _FakeNotification) -> None: def add_item(self, un: _FakeUserNotification, n: _FakeNotification) -> None:
self._items.append((un, n)) self._items.append((un, n))
@@ -129,6 +130,9 @@ class _FakeNotificationRepository:
count += 1 count += 1
return count return count
async def commit(self) -> None:
self._commit_count += 1
@pytest.fixture @pytest.fixture
def fake_repo() -> _FakeNotificationRepository: def fake_repo() -> _FakeNotificationRepository:
@@ -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
@@ -91,6 +91,34 @@ Protocol verification status:
- `yaoLines` item enum: `少阳 | 少阴 | 老阳 | 老阴` - `yaoLines` item enum: `少阳 | 少阴 | 老阳 | 老阴`
- Additional fields are forbidden. - 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 ### `runtime_mode` rules
- Allowed values: `chat | follow_up`. - Allowed values: `chat | follow_up`.