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:
@@ -44,39 +44,8 @@ When viewing data in the database, use `supabase mcp` tools (`supabase_execute_s
|
||||
|
||||
## Image Handling
|
||||
|
||||
When reading images, use `understand_image` tool instead of `Read` tool, especially when the model supports multimodal capabilities. Only use `Read` tool for non-image files.
|
||||
When reading images, check whether the model has native multimodal capability first. If it does, use `Read` tool to read images directly. If it does not, fall back to `understand_image` tool. Only use `Read` tool for non-image files.
|
||||
|
||||
## Mobile Automation
|
||||
|
||||
Use Midscene Skills for mobile UI automation.
|
||||
|
||||
### When to trigger
|
||||
If the user asks to open app, navigate pages, tap, input text, scroll, verify UI, reproduce bug, or run mobile tests → treat as executable automation, not just explanation.
|
||||
|
||||
### Platform
|
||||
- iOS → use Midscene iOS (requires WebDriverAgent at http://localhost:8100/status)
|
||||
- Android → use Midscene Android (requires `adb devices` available)
|
||||
|
||||
If platform not specified:
|
||||
- Use current project platform if obvious
|
||||
- Otherwise ask
|
||||
|
||||
### Preconditions
|
||||
- iOS: WDA must be ready
|
||||
- Android: device/emulator must be connected
|
||||
|
||||
If not ready → stop and report missing requirement
|
||||
|
||||
### Execution
|
||||
- Perform actual UI actions via Midscene Skills
|
||||
- Do not only describe test plan
|
||||
- Capture result (screen state / success / failure step)
|
||||
|
||||
### Output
|
||||
Return:
|
||||
- success or failure
|
||||
- first failing step (if any)
|
||||
- key observation
|
||||
<!-- TRELLIS:START -->
|
||||
# Trellis Instructions
|
||||
|
||||
|
||||
@@ -45,6 +45,26 @@ This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable.
|
||||
- `AppTheme.light` / `AppTheme.dark` provide complete `ColorScheme` (light + dark). `MaterialApp` wires them via `theme:` / `darkTheme:`.
|
||||
- If a semantic slot is missing from `ColorScheme`, add it to `AppTheme` — do not bypass `colorScheme` with hardcoded values.
|
||||
|
||||
### Page Header (Must)
|
||||
|
||||
All sub-pages (sub-page = any page that is not a home Tab page) `AppBar` must follow:
|
||||
|
||||
- **`centerTitle: true`** — title must be horizontally centered; never left-aligned.
|
||||
- **`backgroundColor`** and **`surfaceTintColor`** should match the page background to avoid visual seams.
|
||||
- Example:
|
||||
|
||||
```dart
|
||||
appBar: AppBar(
|
||||
title: Text('Notifications'),
|
||||
centerTitle: true,
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
surfaceTintColor: colors.surfaceContainerLow,
|
||||
actions: [...],
|
||||
),
|
||||
```
|
||||
|
||||
- When a repeated pattern emerges, extract a reusable component into `shared/widgets/` instead of building `AppBar` independently in each page.
|
||||
|
||||
## Divination Terminology (Must)
|
||||
|
||||
- Divination domain terminology must use fixed Chinese terms in code contracts, protocol fields, and UI semantic labels.
|
||||
|
||||
+16
-1
@@ -53,6 +53,7 @@ class _EryaoAppState extends State<EryaoApp> {
|
||||
List<DivinationResultData> _historyRecords = const <DivinationResultData>[];
|
||||
bool _loadingProfile = false;
|
||||
String? _loadedProfileUserEmail;
|
||||
String? _lastUnreadRefreshedUserId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -77,9 +78,23 @@ class _EryaoAppState extends State<EryaoApp> {
|
||||
sessionStore: _sessionStore,
|
||||
);
|
||||
_authBloc = AuthBloc(repository: authRepository);
|
||||
_authBloc.addListener(_onAuthStateChanged);
|
||||
_bootstrap();
|
||||
}
|
||||
|
||||
void _onAuthStateChanged() {
|
||||
final state = _authBloc.state;
|
||||
if (state.status == AuthStatus.authenticated && state.user != null) {
|
||||
final userId = state.user!.id;
|
||||
if (_lastUnreadRefreshedUserId != userId) {
|
||||
_lastUnreadRefreshedUserId = userId;
|
||||
_notificationBloc.handleEvent(RefreshUnreadCount());
|
||||
}
|
||||
return;
|
||||
}
|
||||
_lastUnreadRefreshedUserId = null;
|
||||
}
|
||||
|
||||
void _ensureCreditsLoaded(String userEmail) {
|
||||
if (_loadingCredits) {
|
||||
return;
|
||||
@@ -357,6 +372,7 @@ class _EryaoAppState extends State<EryaoApp> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authBloc.removeListener(_onAuthStateChanged);
|
||||
_authBloc.dispose();
|
||||
_notificationBloc.dispose();
|
||||
super.dispose();
|
||||
@@ -427,7 +443,6 @@ class _EryaoAppState extends State<EryaoApp> {
|
||||
_ensureCreditsLoaded(state.user!.email);
|
||||
_ensureHistoryLoaded(state.user!.email);
|
||||
_refreshProfile(userEmail: state.user!.email);
|
||||
_notificationBloc.handleEvent(RefreshUnreadCount());
|
||||
return HomeScreen(
|
||||
account: state.user!.email,
|
||||
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/divination/divination_shared_widgets.dart';
|
||||
import '../../../../shared/widgets/divination/divination_terms.dart';
|
||||
import '../../../../shared/widgets/divination/yao_legend.dart';
|
||||
import '../../../../shared/widgets/divination/yao_line_row.dart';
|
||||
import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
@@ -23,6 +22,7 @@ import '../../data/models/divination_backend_models.dart';
|
||||
import '../../data/apis/divination_api.dart';
|
||||
import '../../data/models/divination_params.dart';
|
||||
import '../../data/models/divination_result.dart';
|
||||
import '../../data/models/yao_coin_converter.dart';
|
||||
import '../../data/services/divination_run_service.dart';
|
||||
import 'divination_processing_screen.dart';
|
||||
|
||||
@@ -287,14 +287,8 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
|
||||
final c1 = _random.nextBool();
|
||||
final c2 = _random.nextBool();
|
||||
final c3 = _random.nextBool();
|
||||
final yangCount = [c1, c2, c3].where((v) => v).length;
|
||||
final yao = switch (yangCount) {
|
||||
0 => YaoType.oldYin,
|
||||
1 => YaoType.youngYang,
|
||||
2 => YaoType.youngYin,
|
||||
3 => YaoType.oldYang,
|
||||
_ => YaoType.undetermined,
|
||||
};
|
||||
final ziCount = [c1, c2, c3].where((v) => v).length;
|
||||
final yao = YaoCoinConverter.fromZiCount(ziCount);
|
||||
setState(() {
|
||||
_isSpinning = false;
|
||||
_coin1Yang = c1;
|
||||
@@ -737,7 +731,6 @@ class _HexagramCard extends StatelessWidget {
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
for (int i = 5; i >= 0; i--) _YaoRow(index: i, type: yaoStates[i]),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
const Align(alignment: Alignment.centerLeft, child: YaoLegend()),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -10,7 +10,6 @@ import '../../../../shared/theme/app_color_palette.dart';
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/divination/divination_terms.dart';
|
||||
import '../../../../shared/widgets/divination/yao_glyph.dart';
|
||||
import '../../../../shared/widgets/divination/yao_legend.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../data/apis/divination_api.dart';
|
||||
@@ -926,11 +925,6 @@ class _HexagramDetailCard extends StatelessWidget {
|
||||
showTarget:
|
||||
data.hasChangingYao && idx < data.targetYaoLines.length,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: YaoLegend(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1156,7 +1150,10 @@ class _YaoDetailRow extends StatelessWidget {
|
||||
),
|
||||
SizedBox(
|
||||
width: 28,
|
||||
child: Text(data.relation, textAlign: TextAlign.center),
|
||||
child: Text(
|
||||
_abbreviateRelation(data.relation),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 18,
|
||||
@@ -1183,4 +1180,15 @@ class _YaoDetailRow extends StatelessWidget {
|
||||
String _changeMark(YaoType type) {
|
||||
return type.changeMark;
|
||||
}
|
||||
|
||||
String _abbreviateRelation(String relation) {
|
||||
return switch (relation) {
|
||||
'子孙' => '孙',
|
||||
'妻财' => '财',
|
||||
'官鬼' => '官',
|
||||
'兄弟' => '兄',
|
||||
'父母' => '父',
|
||||
_ => relation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import '../../../../shared/widgets/app_modal_dialog.dart';
|
||||
import '../../../../shared/widgets/gua_icon.dart';
|
||||
import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
|
||||
import '../../../../shared/widgets/divination/divination_terms.dart';
|
||||
import '../../../../shared/widgets/divination/yao_legend.dart';
|
||||
import '../../../../shared/widgets/divination/yao_line_row.dart';
|
||||
import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
@@ -20,6 +19,7 @@ import '../../data/models/divination_backend_models.dart';
|
||||
import '../../data/apis/divination_api.dart';
|
||||
import '../../data/models/divination_params.dart';
|
||||
import '../../data/models/divination_result.dart';
|
||||
import '../../data/models/yao_coin_converter.dart';
|
||||
import '../../data/services/divination_run_service.dart';
|
||||
import 'divination_processing_screen.dart';
|
||||
|
||||
@@ -524,7 +524,6 @@ class _YaoSelectionCard extends StatelessWidget {
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
const Align(alignment: Alignment.centerLeft, child: YaoLegend()),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -565,13 +564,7 @@ class _ThreeCoinSelectorDialogState extends State<_ThreeCoinSelectorDialog> {
|
||||
|
||||
YaoType get _currentYaoType {
|
||||
final huaCount = _coinStates.where((isHua) => isHua).length;
|
||||
return switch (huaCount) {
|
||||
0 => YaoType.oldYin,
|
||||
1 => YaoType.youngYang,
|
||||
2 => YaoType.youngYin,
|
||||
3 => YaoType.oldYang,
|
||||
_ => YaoType.undetermined,
|
||||
};
|
||||
return YaoCoinConverter.fromHuaCount(huaCount);
|
||||
}
|
||||
|
||||
void _toggleCoin(int index) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/auth/session_store.dart';
|
||||
import '../../../../data/network/api_client.dart';
|
||||
import '../../../divination/presentation/screens/divination_screen.dart';
|
||||
import '../../../divination/presentation/screens/divination_result_screen.dart';
|
||||
import '../../../divination/data/apis/divination_api.dart';
|
||||
@@ -11,8 +12,11 @@ import '../../../divination/data/models/divination_result.dart';
|
||||
import '../../../notifications/data/repositories/notification_repository.dart';
|
||||
import '../../../notifications/presentation/bloc/notification_bloc.dart';
|
||||
import '../../../notifications/presentation/screens/notification_center_screen.dart';
|
||||
import '../../../settings/data/apis/invite_api.dart';
|
||||
import '../../../settings/data/models/profile_settings.dart';
|
||||
import '../../../settings/data/repositories/invite_repository.dart';
|
||||
import '../../../settings/presentation/screens/settings_screen.dart';
|
||||
import '../../../../app/di/injection.dart';
|
||||
import '../../../../l10n/app_localizations.dart';
|
||||
import '../../../../shared/theme/app_color_palette.dart';
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
@@ -68,10 +72,18 @@ class HomeScreen extends StatefulWidget {
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
MainTab _currentTab = MainTab.home;
|
||||
late final InviteRepository _inviteRepository;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final inviteApi = InviteApi(
|
||||
apiClient: ApiClient(
|
||||
baseUrl: appDependencies.backendUrl,
|
||||
tokenProvider: widget.sessionStore.getToken,
|
||||
),
|
||||
);
|
||||
_inviteRepository = InviteRepositoryImpl(inviteApi: inviteApi);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_tryShowWelcomeDialog();
|
||||
});
|
||||
@@ -120,6 +132,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
account: widget.account,
|
||||
settings: widget.profileSettings,
|
||||
coinBalance: widget.coinBalance,
|
||||
inviteRepository: _inviteRepository,
|
||||
onLocaleChanged: widget.onLocaleChanged,
|
||||
onSettingsChanged: widget.onProfileSettingsChanged,
|
||||
onSaveProfile: widget.onSaveProfile,
|
||||
@@ -209,6 +222,11 @@ class _HomeTab extends StatelessWidget {
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => NotificationCenterScreen(
|
||||
repository: notificationRepository,
|
||||
onUnreadCountChanged: () {
|
||||
return notificationBloc.handleEvent(
|
||||
RefreshUnreadCount(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -532,6 +550,7 @@ class _ProfileTab extends StatelessWidget {
|
||||
required this.account,
|
||||
required this.settings,
|
||||
required this.coinBalance,
|
||||
required this.inviteRepository,
|
||||
required this.onLocaleChanged,
|
||||
required this.onSettingsChanged,
|
||||
required this.onSaveProfile,
|
||||
@@ -543,6 +562,7 @@ class _ProfileTab extends StatelessWidget {
|
||||
final String account;
|
||||
final ProfileSettingsV1 settings;
|
||||
final int coinBalance;
|
||||
final InviteRepository inviteRepository;
|
||||
final Future<void> Function(String languageTag) onLocaleChanged;
|
||||
final Future<void> Function(ProfileSettingsV1 settings) onSettingsChanged;
|
||||
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
|
||||
@@ -557,6 +577,7 @@ class _ProfileTab extends StatelessWidget {
|
||||
account: account,
|
||||
settings: settings,
|
||||
coinBalance: coinBalance,
|
||||
inviteRepository: inviteRepository,
|
||||
onInterfaceLanguageChanged: onLocaleChanged,
|
||||
onSettingsChanged: onSettingsChanged,
|
||||
onSaveProfile: onSaveProfile,
|
||||
|
||||
@@ -60,11 +60,20 @@ class NotificationApi {
|
||||
}
|
||||
|
||||
Future<NotificationItem> markRead({required String notificationId}) async {
|
||||
_logger.info(
|
||||
message: 'Mark read request started',
|
||||
extra: {'notification_id': notificationId},
|
||||
);
|
||||
try {
|
||||
final response = await _apiClient.rawDio.patch<Map<String, dynamic>>(
|
||||
'/api/v1/notifications/$notificationId/read',
|
||||
);
|
||||
return parseNotificationItem(response.data!);
|
||||
final item = parseNotificationItem(response.data!);
|
||||
_logger.info(
|
||||
message: 'Mark read request succeeded',
|
||||
extra: {'notification_id': notificationId, 'is_read': item.isRead},
|
||||
);
|
||||
return item;
|
||||
} on DioException catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Mark read failed',
|
||||
@@ -76,11 +85,17 @@ class NotificationApi {
|
||||
}
|
||||
|
||||
Future<int> markAllRead() async {
|
||||
_logger.info(message: 'Mark all read request started');
|
||||
try {
|
||||
final response = await _apiClient.rawDio.patch<Map<String, dynamic>>(
|
||||
'/api/v1/notifications/mark-all-read',
|
||||
);
|
||||
return response.data?['updatedCount'] as int? ?? 0;
|
||||
final updatedCount = response.data?['updatedCount'] as int? ?? 0;
|
||||
_logger.info(
|
||||
message: 'Mark all read request succeeded',
|
||||
extra: {'updated_count': updatedCount},
|
||||
);
|
||||
return updatedCount;
|
||||
} on DioException catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Mark all read failed',
|
||||
|
||||
@@ -185,58 +185,64 @@ class NotificationBloc extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _markRead(String notificationId) async {
|
||||
final previousItems = _state.items;
|
||||
final previousCount = _state.unreadCount;
|
||||
final idx = _state.items.indexWhere((item) => item.id == notificationId);
|
||||
if (idx == -1) return;
|
||||
if (_state.items[idx].isRead) return;
|
||||
|
||||
final wasUnread = !_state.items[idx].isRead;
|
||||
_state = _state.copyWith(
|
||||
items: [
|
||||
..._state.items.sublist(0, idx),
|
||||
_state.items[idx].copyWith(isRead: true),
|
||||
..._state.items.sublist(idx + 1),
|
||||
],
|
||||
unreadCount: wasUnread
|
||||
? (_state.unreadCount > 0 ? _state.unreadCount - 1 : 0)
|
||||
: _state.unreadCount,
|
||||
_logger.info(
|
||||
message: 'Mark notification read started',
|
||||
extra: {'notification_id': notificationId},
|
||||
);
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _repository.markRead(notificationId: notificationId);
|
||||
final updated = await _repository.markRead(
|
||||
notificationId: notificationId,
|
||||
);
|
||||
final targetIndex = _state.items.indexWhere(
|
||||
(item) => item.id == updated.id,
|
||||
);
|
||||
if (targetIndex == -1) {
|
||||
return;
|
||||
}
|
||||
_state = _state.copyWith(
|
||||
items: [
|
||||
..._state.items.sublist(0, targetIndex),
|
||||
updated,
|
||||
..._state.items.sublist(targetIndex + 1),
|
||||
],
|
||||
unreadCount: _state.unreadCount > 0 ? _state.unreadCount - 1 : 0,
|
||||
);
|
||||
notifyListeners();
|
||||
_logger.info(
|
||||
message: 'Mark notification read succeeded',
|
||||
extra: {'notification_id': notificationId},
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Mark read failed: ${error.runtimeType}',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
_state = _state.copyWith(
|
||||
items: previousItems,
|
||||
unreadCount: previousCount,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markAllRead() async {
|
||||
final previousItems = _state.items;
|
||||
_state = _state.copyWith(
|
||||
items: _state.items.map((item) => item.copyWith(isRead: true)).toList(),
|
||||
unreadCount: 0,
|
||||
);
|
||||
notifyListeners();
|
||||
_logger.info(message: 'Mark all notifications read started');
|
||||
|
||||
try {
|
||||
await _repository.markAllRead();
|
||||
_state = _state.copyWith(
|
||||
items: _state.items.map((item) => item.copyWith(isRead: true)).toList(),
|
||||
unreadCount: 0,
|
||||
);
|
||||
notifyListeners();
|
||||
_logger.info(message: 'Mark all notifications read succeeded');
|
||||
} catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Mark all read failed: ${error.runtimeType}',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
_state = _state.copyWith(items: previousItems);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
-4
@@ -1,6 +1,9 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/notification/notification_detail_bottom_sheet.dart';
|
||||
import '../../data/models/notification_item.dart';
|
||||
import '../../data/models/notification_payload.dart';
|
||||
import '../../data/repositories/notification_repository.dart';
|
||||
@@ -13,12 +16,14 @@ class NotificationCenterScreen extends StatefulWidget {
|
||||
required this.repository,
|
||||
this.onNavigateToRoute,
|
||||
this.onOpenUrl,
|
||||
this.onUnreadCountChanged,
|
||||
});
|
||||
|
||||
final NotificationRepository repository;
|
||||
final void Function(String route, {String? entityId, String? tab})?
|
||||
onNavigateToRoute;
|
||||
final void Function(String url)? onOpenUrl;
|
||||
final Future<void> Function()? onUnreadCountChanged;
|
||||
|
||||
@override
|
||||
State<NotificationCenterScreen> createState() =>
|
||||
@@ -55,6 +60,7 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('通知'),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
if (state.items.any((item) => !item.isRead))
|
||||
TextButton(
|
||||
@@ -136,15 +142,32 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
|
||||
final item = state.items[index];
|
||||
return NotificationListItem(
|
||||
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) {
|
||||
_bloc.handleEvent(MarkNotificationRead(notificationId: item.id));
|
||||
await _bloc.handleEvent(MarkNotificationRead(notificationId: item.id));
|
||||
final updatedIndex = _bloc.state.items.indexWhere((n) => n.id == item.id);
|
||||
if (wasUnread &&
|
||||
updatedIndex >= 0 &&
|
||||
_bloc.state.items[updatedIndex].isRead) {
|
||||
await widget.onUnreadCountChanged?.call();
|
||||
}
|
||||
}
|
||||
if (context.mounted) {
|
||||
await showNotificationDetailBottomSheet(
|
||||
context: context,
|
||||
item: item,
|
||||
onMarkRead: () async {},
|
||||
);
|
||||
}
|
||||
_executePayload(item.payload);
|
||||
}
|
||||
@@ -161,6 +184,15 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
|
||||
}
|
||||
|
||||
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,73 +18,78 @@ class NotificationListItem extends StatelessWidget {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: item.isRead ? colors.surface : colors.surfaceContainerHighest,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: colors.outlineVariant.withValues(alpha: 0.3),
|
||||
width: 0.5,
|
||||
return IntrinsicHeight(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: item.isRead
|
||||
? colors.surface
|
||||
: colors.surfaceContainerHighest,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: colors.outlineVariant.withValues(alpha: 0.3),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!item.isRead)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(
|
||||
top: AppSpacing.sm,
|
||||
right: AppSpacing.sm,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!item.isRead)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(
|
||||
top: AppSpacing.sm,
|
||||
right: AppSpacing.sm,
|
||||
),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary,
|
||||
shape: BoxShape.circle,
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: item.isRead
|
||||
? FontWeight.normal
|
||||
: FontWeight.w600,
|
||||
color: colors.onSurface,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
item.body,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
_formatTime(item.createdAt),
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: colors.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: item.isRead
|
||||
? FontWeight.normal
|
||||
: FontWeight.w600,
|
||||
color: colors.onSurface,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
item.body,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
_formatTime(item.createdAt),
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: colors.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.errorContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: colors.error.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Text(
|
||||
l10n.settingsDeleteAccountReRegisterNotice,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onErrorContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
_secondsLeft > 0
|
||||
|
||||
@@ -1,44 +1,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../../core/logging/logger.dart';
|
||||
import '../../../../l10n/app_localizations.dart';
|
||||
import '../../../../shared/theme/app_color_palette.dart';
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../data/repositories/invite_repository.dart';
|
||||
|
||||
class InviteScreen extends StatefulWidget {
|
||||
const InviteScreen({super.key});
|
||||
const InviteScreen({super.key, required this.inviteRepository});
|
||||
|
||||
final InviteRepository inviteRepository;
|
||||
|
||||
@override
|
||||
State<InviteScreen> createState() => _InviteScreenState();
|
||||
}
|
||||
|
||||
class _InviteScreenState extends State<InviteScreen> {
|
||||
final Logger _logger = getLogger('features.settings.invite_screen');
|
||||
final _bindCodeController = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
bool _isBinding = false;
|
||||
bool _isGenerating = false;
|
||||
bool _isLoading = true;
|
||||
bool _hasError = false;
|
||||
|
||||
// Mock data - will be replaced with API calls
|
||||
final String _myInviteCode = 'ABC123';
|
||||
final int _invitedCount = 3;
|
||||
String? _myInviteCode;
|
||||
int _invitedCount = 0;
|
||||
final bool _hasInviter = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadInviteCode();
|
||||
}
|
||||
|
||||
Future<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
|
||||
void dispose() {
|
||||
_bindCodeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _hasMyInviteCode => _myInviteCode.isNotEmpty;
|
||||
bool get _hasMyInviteCode =>
|
||||
_myInviteCode != null && _myInviteCode!.isNotEmpty;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
if (_isLoading) {
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.settingsInviteTitle),
|
||||
centerTitle: true,
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
surfaceTintColor: colors.surfaceContainerLow,
|
||||
),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (_hasError) {
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.settingsInviteTitle),
|
||||
centerTitle: true,
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
surfaceTintColor: colors.surfaceContainerLow,
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
l10n.settingsInviteEmptyTitle,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
FilledButton(
|
||||
onPressed: _loadInviteCode,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
appBar: AppBar(
|
||||
@@ -51,7 +132,10 @@ class _InviteScreenState extends State<InviteScreen> {
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
children: [
|
||||
if (_hasMyInviteCode) ...[
|
||||
_InviteCodeCard(inviteCode: _myInviteCode, onCopy: _copyInviteCode),
|
||||
_InviteCodeCard(
|
||||
inviteCode: _myInviteCode!,
|
||||
onCopy: _copyInviteCode,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_InviteStatsCard(count: _invitedCount),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
@@ -79,7 +163,7 @@ class _InviteScreenState extends State<InviteScreen> {
|
||||
|
||||
void _copyInviteCode() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
Clipboard.setData(ClipboardData(text: _myInviteCode));
|
||||
Clipboard.setData(ClipboardData(text: _myInviteCode!));
|
||||
Toast.show(
|
||||
context,
|
||||
l10n.settingsInviteCopySuccess,
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_modal_dialog.dart';
|
||||
import '../../../../shared/widgets/gua_icon.dart';
|
||||
import '../../data/models/profile_settings.dart';
|
||||
import '../../data/repositories/invite_repository.dart';
|
||||
import 'account_delete_screen.dart';
|
||||
import '../widgets/settings_section_widgets.dart';
|
||||
import 'coin_center_screen.dart';
|
||||
@@ -19,6 +20,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
required this.account,
|
||||
required this.settings,
|
||||
required this.coinBalance,
|
||||
required this.inviteRepository,
|
||||
required this.onInterfaceLanguageChanged,
|
||||
required this.onSettingsChanged,
|
||||
required this.onUploadAvatar,
|
||||
@@ -30,6 +32,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
final String account;
|
||||
final ProfileSettingsV1 settings;
|
||||
final int coinBalance;
|
||||
final InviteRepository inviteRepository;
|
||||
final Future<void> Function(String languageTag) onInterfaceLanguageChanged;
|
||||
final Future<void> Function(ProfileSettingsV1 settings) onSettingsChanged;
|
||||
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
|
||||
@@ -179,9 +182,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
Future<void> _openInvite() async {
|
||||
await Navigator.of(
|
||||
context,
|
||||
).push<void>(MaterialPageRoute<void>(builder: (_) => const InviteScreen()));
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => InviteScreen(inviteRepository: widget.inviteRepository),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openProfileEdit() async {
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"settingsDeleteAccountSubtitle": "Permanently delete your account and personal data",
|
||||
"settingsDeleteAccountWarningTitle": "Please confirm before deleting",
|
||||
"settingsDeleteAccountWarningBody": "After deletion, related data including profile, history, and points will be permanently removed and cannot be restored.",
|
||||
"settingsDeleteAccountReRegisterNotice": "Important: if you delete and re-register with the same email, consumed points will not be reset or refunded.",
|
||||
"settingsDeleteAccountScopeProfile": "Profile and account information will be deleted",
|
||||
"settingsDeleteAccountScopeHistory": "Divination history records will be deleted",
|
||||
"settingsDeleteAccountScopePoints": "Points account and ledger records will be deleted",
|
||||
@@ -295,7 +296,7 @@
|
||||
"questionTypeSearch": "Search",
|
||||
"questionTypeOther": "Other",
|
||||
"toastPleaseInputQuestion": "Please enter your question",
|
||||
"toastCoinInsufficient": "Insufficient coins",
|
||||
"toastCoinInsufficient": "Insufficient points",
|
||||
"divinationCostDialogTitle": "Confirm divination",
|
||||
"divinationCostDialogBody": "This run costs {cost} credits. Available balance: {balance} credits. Continue?",
|
||||
"@divinationCostDialogBody": {
|
||||
|
||||
@@ -758,6 +758,12 @@ abstract class AppLocalizations {
|
||||
/// **'删除账号后,个人资料、历史记录、点数信息等相关数据将被永久删除,且不可恢复。'**
|
||||
String get settingsDeleteAccountWarningBody;
|
||||
|
||||
/// No description provided for @settingsDeleteAccountReRegisterNotice.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'重要提示:同一邮箱删除后重新注册,已消耗积分不会重置或返还。'**
|
||||
String get settingsDeleteAccountReRegisterNotice;
|
||||
|
||||
/// No description provided for @settingsDeleteAccountScopeProfile.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
@@ -1487,7 +1493,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @toastCoinInsufficient.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'铜钱不足,无法解卦'**
|
||||
/// **'积分不足,无法解卦'**
|
||||
String get toastCoinInsufficient;
|
||||
|
||||
/// No description provided for @divinationCostDialogTitle.
|
||||
|
||||
@@ -367,6 +367,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get settingsDeleteAccountWarningBody =>
|
||||
'After deletion, related data including profile, history, and points will be permanently removed and cannot be restored.';
|
||||
|
||||
@override
|
||||
String get settingsDeleteAccountReRegisterNotice =>
|
||||
'Important: if you delete and re-register with the same email, consumed points will not be reset or refunded.';
|
||||
|
||||
@override
|
||||
String get settingsDeleteAccountScopeProfile =>
|
||||
'Profile and account information will be deleted';
|
||||
@@ -770,7 +774,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get toastPleaseInputQuestion => 'Please enter your question';
|
||||
|
||||
@override
|
||||
String get toastCoinInsufficient => 'Insufficient coins';
|
||||
String get toastCoinInsufficient => 'Insufficient points';
|
||||
|
||||
@override
|
||||
String get divinationCostDialogTitle => 'Confirm divination';
|
||||
|
||||
@@ -359,6 +359,10 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get settingsDeleteAccountWarningBody =>
|
||||
'删除账号后,个人资料、历史记录、点数信息等相关数据将被永久删除,且不可恢复。';
|
||||
|
||||
@override
|
||||
String get settingsDeleteAccountReRegisterNotice =>
|
||||
'重要提示:同一邮箱删除后重新注册,已消耗积分不会重置或返还。';
|
||||
|
||||
@override
|
||||
String get settingsDeleteAccountScopeProfile => '个人资料和账号信息会被删除';
|
||||
|
||||
@@ -737,7 +741,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get toastPleaseInputQuestion => '请输入您想占卜的问题';
|
||||
|
||||
@override
|
||||
String get toastCoinInsufficient => '铜钱不足,无法解卦';
|
||||
String get toastCoinInsufficient => '积分不足,无法解卦';
|
||||
|
||||
@override
|
||||
String get divinationCostDialogTitle => '确认开始解卦';
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"settingsDeleteAccountSubtitle": "永久删除账号及相关个人数据",
|
||||
"settingsDeleteAccountWarningTitle": "删除前请确认",
|
||||
"settingsDeleteAccountWarningBody": "删除账号后,个人资料、历史记录、点数信息等相关数据将被永久删除,且不可恢复。",
|
||||
"settingsDeleteAccountReRegisterNotice": "重要提示:同一邮箱删除后重新注册,已消耗积分不会重置或返还。",
|
||||
"settingsDeleteAccountScopeProfile": "个人资料和账号信息会被删除",
|
||||
"settingsDeleteAccountScopeHistory": "历史解卦记录会被删除",
|
||||
"settingsDeleteAccountScopePoints": "点数账户与流水记录会被删除",
|
||||
@@ -295,7 +296,7 @@
|
||||
"questionTypeSearch": "寻物",
|
||||
"questionTypeOther": "其他",
|
||||
"toastPleaseInputQuestion": "请输入您想占卜的问题",
|
||||
"toastCoinInsufficient": "铜钱不足,无法解卦",
|
||||
"toastCoinInsufficient": "积分不足,无法解卦",
|
||||
"divinationCostDialogTitle": "确认开始解卦",
|
||||
"divinationCostDialogBody": "本次解卦将消耗 {cost} 点数,当前可用 {balance} 点数。是否继续?",
|
||||
"@divinationCostDialogBody": {
|
||||
|
||||
@@ -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 = [];
|
||||
int unreadCount = 0;
|
||||
int markAllReadCallCount = 0;
|
||||
bool failMarkRead = false;
|
||||
bool failMarkAllRead = false;
|
||||
|
||||
@override
|
||||
Future<NotificationListResult> listNotifications({
|
||||
@@ -28,6 +30,9 @@ class _FakeNotificationRepository implements NotificationRepository {
|
||||
|
||||
@override
|
||||
Future<NotificationItem> markRead({required String notificationId}) async {
|
||||
if (failMarkRead) {
|
||||
throw Exception('Mark read failed');
|
||||
}
|
||||
final idx = items.indexWhere((i) => i.id == notificationId);
|
||||
if (idx == -1) {
|
||||
throw Exception('Not found');
|
||||
@@ -39,6 +44,9 @@ class _FakeNotificationRepository implements NotificationRepository {
|
||||
|
||||
@override
|
||||
Future<int> markAllRead() async {
|
||||
if (failMarkAllRead) {
|
||||
throw Exception('Mark all read failed');
|
||||
}
|
||||
markAllReadCallCount++;
|
||||
final count = unreadCount;
|
||||
for (int i = 0; i < items.length; i++) {
|
||||
@@ -99,6 +107,21 @@ void main() {
|
||||
expect(bloc.state.unreadCount, 0);
|
||||
});
|
||||
|
||||
test(
|
||||
'MarkNotificationRead does not update state when request fails',
|
||||
() async {
|
||||
fakeRepo.items.add(makeItem(id: 'n1', isRead: false));
|
||||
fakeRepo.unreadCount = 1;
|
||||
fakeRepo.failMarkRead = true;
|
||||
await bloc.handleEvent(LoadNotifications());
|
||||
await bloc.handleEvent(RefreshUnreadCount());
|
||||
|
||||
await bloc.handleEvent(MarkNotificationRead(notificationId: 'n1'));
|
||||
expect(bloc.state.items.first.isRead, false);
|
||||
expect(bloc.state.unreadCount, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test('MarkAllNotificationsRead marks all as read', () async {
|
||||
fakeRepo.items.addAll([
|
||||
makeItem(id: 'n1', isRead: false),
|
||||
@@ -112,6 +135,24 @@ void main() {
|
||||
expect(bloc.state.items.every((i) => i.isRead), true);
|
||||
});
|
||||
|
||||
test(
|
||||
'MarkAllNotificationsRead does not update state when request fails',
|
||||
() async {
|
||||
fakeRepo.items.addAll([
|
||||
makeItem(id: 'n1', isRead: false),
|
||||
makeItem(id: 'n2', isRead: false),
|
||||
]);
|
||||
fakeRepo.unreadCount = 2;
|
||||
fakeRepo.failMarkAllRead = true;
|
||||
await bloc.handleEvent(LoadNotifications());
|
||||
await bloc.handleEvent(RefreshUnreadCount());
|
||||
|
||||
await bloc.handleEvent(MarkAllNotificationsRead());
|
||||
expect(bloc.state.unreadCount, 2);
|
||||
expect(bloc.state.items.every((i) => !i.isRead), true);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'NotificationCreatedEvent adds item and increments unreadCount',
|
||||
() async {
|
||||
|
||||
@@ -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,13 +384,14 @@ async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]:
|
||||
runtime_config=runtime_config,
|
||||
cancel_checker=_cancel_checker,
|
||||
)
|
||||
await points_service.consume_successful_run_points(
|
||||
user_id=owner_id,
|
||||
session_id=UUID(thread_id),
|
||||
run_id=run_id,
|
||||
operator_id=owner_id,
|
||||
user_email=owner_email,
|
||||
)
|
||||
if runtime_mode == RuntimeMode.CHAT:
|
||||
await points_service.consume_successful_run_points(
|
||||
user_id=owner_id,
|
||||
session_id=UUID(thread_id),
|
||||
run_id=run_id,
|
||||
operator_id=owner_id,
|
||||
user_email=owner_email,
|
||||
)
|
||||
await session.commit()
|
||||
except asyncio.CancelledError:
|
||||
await points_service.record_failed_run_platform_cost(
|
||||
|
||||
@@ -9,7 +9,7 @@ from uuid import UUID
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
|
||||
|
||||
from v1.notifications.schemas import (
|
||||
from backend.src.schemas.shared.notification import (
|
||||
NotificationPayload,
|
||||
NotificationPayloadNone,
|
||||
)
|
||||
|
||||
@@ -39,6 +39,7 @@ def build_logging_config(runtime: RuntimeSettings) -> dict[str, object]:
|
||||
file_path=log_dir / runtime.log_file_name,
|
||||
level=runtime.log_level,
|
||||
formatter=formatter_name,
|
||||
filters=["suppress_httpx_auth_noise"],
|
||||
)
|
||||
error_handler = build_file_handler_config(
|
||||
runtime,
|
||||
@@ -54,7 +55,10 @@ def build_logging_config(runtime: RuntimeSettings) -> dict[str, object]:
|
||||
"filters": {
|
||||
"error_only": {
|
||||
"()": "core.logging.filters.ErrorLevelFilter",
|
||||
}
|
||||
},
|
||||
"suppress_httpx_auth_noise": {
|
||||
"()": "core.logging.filters.HttpxAuthNoiseFilter",
|
||||
},
|
||||
},
|
||||
"formatters": {
|
||||
"json": {
|
||||
|
||||
@@ -54,3 +54,16 @@ def build_sensitive_data_processor(
|
||||
class ErrorLevelFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
return record.levelno >= logging.ERROR
|
||||
|
||||
|
||||
class HttpxAuthNoiseFilter(logging.Filter):
|
||||
_SUPPRESSED_FRAGMENTS = (
|
||||
"/auth/v1/user",
|
||||
"/auth/v1/token?grant_type=refresh_token",
|
||||
)
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
if record.levelno >= logging.WARNING:
|
||||
return True
|
||||
message = record.getMessage()
|
||||
return not any(fragment in message for fragment in self._SUPPRESSED_FRAGMENTS)
|
||||
|
||||
@@ -25,7 +25,7 @@ class SpecialMark(str, Enum):
|
||||
|
||||
|
||||
class YaoDetail(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
position: int = Field(ge=1, le=6)
|
||||
spirit_name: str = Field(alias="spiritName", min_length=1)
|
||||
@@ -38,7 +38,7 @@ class YaoDetail(BaseModel):
|
||||
|
||||
|
||||
class FushenDetail(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
position: int = Field(ge=1, le=6)
|
||||
relation_name: str = Field(alias="relationName", min_length=1)
|
||||
@@ -47,7 +47,7 @@ class FushenDetail(BaseModel):
|
||||
|
||||
|
||||
class GanzhiDetail(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
year_gan_zhi: str = Field(alias="yearGanZhi", min_length=2, max_length=2)
|
||||
month_gan_zhi: str = Field(alias="monthGanZhi", min_length=2, max_length=2)
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
@@ -170,7 +170,7 @@ class AgentRepository:
|
||||
session_row.last_activity_at = datetime.now(timezone.utc)
|
||||
await self._session.flush()
|
||||
|
||||
async def get_user_message_count(self, *, session_id: str) -> int:
|
||||
async def get_assistant_message_count(self, *, session_id: str) -> int:
|
||||
try:
|
||||
session_uuid = UUID(session_id)
|
||||
except ValueError as exc:
|
||||
@@ -184,7 +184,7 @@ class AgentRepository:
|
||||
select(func.count(AgentChatMessage.id))
|
||||
.where(AgentChatMessage.session_id == session_uuid)
|
||||
.where(AgentChatMessage.deleted_at.is_(None))
|
||||
.where(AgentChatMessage.role == AgentChatMessageRole.USER)
|
||||
.where(AgentChatMessage.role == AgentChatMessageRole.ASSISTANT)
|
||||
)
|
||||
count = (await self._session.execute(stmt)).scalar_one()
|
||||
return int(count)
|
||||
@@ -266,7 +266,11 @@ class AgentRepository:
|
||||
).scalar_one_or_none() is not None
|
||||
snapshot_messages: list[dict[str, object]] = []
|
||||
for message in messages:
|
||||
snapshot_messages.append(await self._to_snapshot_message(message))
|
||||
snapshot_messages.append(
|
||||
(await self._to_chat_message_schema(message)).model_dump(
|
||||
mode="json", by_alias=True, exclude_none=True
|
||||
)
|
||||
)
|
||||
return {
|
||||
"day": target_day.isoformat(),
|
||||
"hasMore": has_more,
|
||||
@@ -278,7 +282,7 @@ class AgentRepository:
|
||||
*,
|
||||
session_id: str,
|
||||
visibility_mask: int | None = None,
|
||||
) -> list[dict[str, object]]:
|
||||
) -> list[AgentChatMessageSchema]:
|
||||
try:
|
||||
session_uuid = UUID(session_id)
|
||||
except ValueError as exc:
|
||||
@@ -299,9 +303,9 @@ class AgentRepository:
|
||||
visibility_mask=visibility_mask,
|
||||
)
|
||||
messages = (await self._session.execute(message_stmt)).scalars().all()
|
||||
snapshot_messages: list[dict[str, object]] = []
|
||||
snapshot_messages: list[AgentChatMessageSchema] = []
|
||||
for message in messages:
|
||||
snapshot_messages.append(await self._to_snapshot_message(message))
|
||||
snapshot_messages.append(await self._to_chat_message_schema(message))
|
||||
return snapshot_messages
|
||||
|
||||
async def get_recent_messages_by_user_window(
|
||||
@@ -352,7 +356,11 @@ class AgentRepository:
|
||||
selected = list(reversed(selected_desc))
|
||||
snapshot_messages: list[dict[str, object]] = []
|
||||
for message in selected:
|
||||
snapshot_messages.append(await self._to_snapshot_message(message))
|
||||
snapshot_messages.append(
|
||||
(await self._to_chat_message_schema(message)).model_dump(
|
||||
mode="json", by_alias=True, exclude_none=True
|
||||
)
|
||||
)
|
||||
return snapshot_messages
|
||||
|
||||
async def get_latest_session_id_for_user(self, *, user_id: str) -> str | None:
|
||||
@@ -382,7 +390,7 @@ class AgentRepository:
|
||||
user_id: str,
|
||||
visibility_mask: int | None = None,
|
||||
session_limit: int = 50,
|
||||
) -> list[dict[str, object]]:
|
||||
) -> list[AgentChatMessageSchema]:
|
||||
try:
|
||||
user_uuid = UUID(user_id)
|
||||
except ValueError as exc:
|
||||
@@ -404,7 +412,7 @@ class AgentRepository:
|
||||
if not session_ids:
|
||||
return []
|
||||
|
||||
snapshots: list[dict[str, object]] = []
|
||||
snapshots: list[AgentChatMessageSchema] = []
|
||||
for session_id in session_ids:
|
||||
message_stmt = (
|
||||
select(AgentChatMessage)
|
||||
@@ -423,10 +431,14 @@ class AgentRepository:
|
||||
)
|
||||
if not candidate_messages:
|
||||
continue
|
||||
selected_snapshot: dict[str, object] | None = None
|
||||
selected_snapshot: AgentChatMessageSchema | None = None
|
||||
for message in candidate_messages:
|
||||
snapshot = await self._to_snapshot_message(message)
|
||||
metadata = snapshot.get("metadata")
|
||||
snapshot = await self._to_chat_message_schema(message)
|
||||
metadata = (
|
||||
snapshot.metadata.model_dump(mode="json", exclude_none=True)
|
||||
if snapshot.metadata is not None
|
||||
else None
|
||||
)
|
||||
if not isinstance(metadata, dict):
|
||||
continue
|
||||
agent_output = metadata.get("agent_output")
|
||||
@@ -440,7 +452,7 @@ class AgentRepository:
|
||||
snapshots.append(selected_snapshot)
|
||||
|
||||
snapshots.sort(
|
||||
key=lambda item: str(item.get("timestamp") or ""),
|
||||
key=lambda item: str(item.timestamp),
|
||||
reverse=True,
|
||||
)
|
||||
return snapshots
|
||||
@@ -462,9 +474,9 @@ class AgentRepository:
|
||||
"config": config_payload,
|
||||
}
|
||||
|
||||
async def _to_snapshot_message(
|
||||
async def _to_chat_message_schema(
|
||||
self, message: AgentChatMessage
|
||||
) -> dict[str, object]:
|
||||
) -> AgentChatMessageSchema:
|
||||
role = (
|
||||
message.role.value
|
||||
if isinstance(message.role, AgentChatMessageRole)
|
||||
@@ -487,7 +499,7 @@ class AgentRepository:
|
||||
"timestamp": message.created_at.astimezone(timezone.utc).isoformat(),
|
||||
}
|
||||
)
|
||||
return payload_model.model_dump(mode="json", exclude_none=True)
|
||||
return payload_model
|
||||
|
||||
def _apply_visibility_filter(
|
||||
self,
|
||||
|
||||
@@ -8,6 +8,7 @@ from uuid import UUID
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from schemas.agent.runtime_models import ErrorInfo
|
||||
from schemas.domain.chat_message import AgentChatMessage
|
||||
from schemas.domain.divination import DerivedDivinationData
|
||||
|
||||
|
||||
@@ -37,7 +38,7 @@ class AgentRepositoryLike(Protocol):
|
||||
*,
|
||||
session_id: str,
|
||||
visibility_mask: int | None = None,
|
||||
) -> list[dict[str, object]]: ...
|
||||
) -> list[AgentChatMessage]: ...
|
||||
|
||||
async def get_latest_session_id_for_user(self, *, user_id: str) -> str | None: ...
|
||||
|
||||
@@ -47,7 +48,7 @@ class AgentRepositoryLike(Protocol):
|
||||
user_id: str,
|
||||
visibility_mask: int | None = None,
|
||||
session_limit: int = 50,
|
||||
) -> list[dict[str, object]]: ...
|
||||
) -> list[AgentChatMessage]: ...
|
||||
|
||||
async def persist_user_message(
|
||||
self,
|
||||
@@ -58,7 +59,7 @@ class AgentRepositoryLike(Protocol):
|
||||
visibility_mask: int,
|
||||
) -> None: ...
|
||||
|
||||
async def get_user_message_count(self, *, session_id: str) -> int: ...
|
||||
async def get_assistant_message_count(self, *, session_id: str) -> int: ...
|
||||
|
||||
async def get_system_agent_config(
|
||||
self, *, agent_type: str
|
||||
|
||||
@@ -46,7 +46,7 @@ from v1.agent.utils import (
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
MAX_RUNS_PER_SESSION = 2
|
||||
MAX_ASSISTANT_MESSAGES_PER_SESSION = 2
|
||||
|
||||
|
||||
def ensure_session_owner(*, owner_id: str, current_user: CurrentUser) -> None:
|
||||
@@ -151,6 +151,7 @@ class AgentService:
|
||||
await self._enforce_run_preconditions(
|
||||
thread_id=thread_id,
|
||||
current_user=current_user,
|
||||
runtime_mode=runtime_mode,
|
||||
)
|
||||
except ApiProblemError:
|
||||
if created:
|
||||
@@ -247,7 +248,7 @@ class AgentService:
|
||||
metadata: AgentChatMessageMetadata | None,
|
||||
) -> None:
|
||||
metadata_payload = (
|
||||
metadata.model_dump(mode="json", exclude_none=True)
|
||||
metadata.model_dump(mode="json", by_alias=True, exclude_none=True)
|
||||
if isinstance(metadata, AgentChatMessageMetadata)
|
||||
else None
|
||||
)
|
||||
@@ -494,19 +495,23 @@ class AgentService:
|
||||
*,
|
||||
thread_id: str,
|
||||
current_user: CurrentUser,
|
||||
runtime_mode: RuntimeMode,
|
||||
) -> None:
|
||||
await self._points_service.ensure_run_points_available(user_id=current_user.id)
|
||||
if runtime_mode == RuntimeMode.CHAT:
|
||||
await self._points_service.ensure_run_points_available(
|
||||
user_id=current_user.id
|
||||
)
|
||||
|
||||
user_message_count = await self._repository.get_user_message_count(
|
||||
assistant_message_count = await self._repository.get_assistant_message_count(
|
||||
session_id=thread_id
|
||||
)
|
||||
if user_message_count >= MAX_RUNS_PER_SESSION:
|
||||
if assistant_message_count >= MAX_ASSISTANT_MESSAGES_PER_SESSION:
|
||||
raise ApiProblemError(
|
||||
status_code=409,
|
||||
detail=problem_payload(
|
||||
code="AGENT_SESSION_RUN_LIMIT_EXCEEDED",
|
||||
detail="Session run limit exceeded",
|
||||
params={"maxRuns": MAX_RUNS_PER_SESSION},
|
||||
params={"maxRuns": MAX_ASSISTANT_MESSAGES_PER_SESSION},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -597,7 +602,6 @@ class AgentService:
|
||||
thread_id: str,
|
||||
current_user: CurrentUser,
|
||||
) -> HistorySnapshotResponse:
|
||||
from schemas.domain.chat_message import AgentChatMessage
|
||||
from v1.agent.utils import convert_message_to_history
|
||||
from v1.agent.schemas import HistoryMessage
|
||||
|
||||
@@ -609,11 +613,9 @@ class AgentService:
|
||||
)
|
||||
|
||||
messages: list[HistoryMessage] = []
|
||||
for msg_dict in raw_messages:
|
||||
msg = AgentChatMessage.model_validate(msg_dict)
|
||||
if msg.role == "tool":
|
||||
for msg in raw_messages:
|
||||
if msg.role not in {"user", "assistant"}:
|
||||
continue
|
||||
|
||||
signed_urls: dict[str, str] = {}
|
||||
attachments = extract_user_message_attachments(msg.metadata)
|
||||
if self._attachment_storage and attachments:
|
||||
@@ -653,7 +655,6 @@ class AgentService:
|
||||
current_user: CurrentUser,
|
||||
thread_id: str | None,
|
||||
) -> HistorySnapshotResponse:
|
||||
from schemas.domain.chat_message import AgentChatMessage
|
||||
from v1.agent.utils import convert_message_to_history
|
||||
from v1.agent.schemas import HistoryMessage
|
||||
|
||||
@@ -675,8 +676,9 @@ class AgentService:
|
||||
visible_messages = raw_messages[:summary_limit]
|
||||
|
||||
messages: list[HistoryMessage] = []
|
||||
for msg_dict in visible_messages:
|
||||
msg = AgentChatMessage.model_validate(msg_dict)
|
||||
for msg in visible_messages:
|
||||
if msg.role != "assistant":
|
||||
continue
|
||||
converted = convert_message_to_history(msg)
|
||||
messages.append(HistoryMessage.model_validate(converted))
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.config.settings import config
|
||||
from core.db import get_db
|
||||
from core.logging import get_logger
|
||||
from v1.notifications.repository import NotificationRepository
|
||||
from v1.notifications.service import NotificationService
|
||||
from v1.auth.rate_limit import enforce_rate_limit
|
||||
from v1.auth.dependencies import get_auth_service
|
||||
from v1.auth.schemas import (
|
||||
@@ -22,6 +25,7 @@ from v1.points.service import PointsService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
logger = get_logger("v1.auth.router")
|
||||
|
||||
|
||||
@router.post("/otp/send", status_code=204)
|
||||
@@ -73,7 +77,16 @@ async def create_email_session(
|
||||
user_id=UUID(result.user.id),
|
||||
user_email=result.user.email,
|
||||
)
|
||||
notification_service = NotificationService(NotificationRepository(session))
|
||||
linked_count = await notification_service.link_published_notifications_to_user(
|
||||
user_id=UUID(result.user.id)
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"Linked published notifications for authenticated user",
|
||||
user_id=result.user.id,
|
||||
linked_count=linked_count,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 uuid import UUID
|
||||
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -111,3 +112,37 @@ class NotificationRepository:
|
||||
await self._session.execute(stmt)
|
||||
await self._session.flush()
|
||||
return count
|
||||
|
||||
async def commit(self) -> None:
|
||||
await self._session.commit()
|
||||
|
||||
async def link_published_notifications_to_user(self, *, user_id: UUID) -> int:
|
||||
notification_ids = list(
|
||||
(
|
||||
await self._session.execute(
|
||||
select(Notification.id).where(
|
||||
Notification.status == "published",
|
||||
Notification.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not notification_ids:
|
||||
return 0
|
||||
|
||||
stmt = (
|
||||
insert(UserNotification)
|
||||
.values(
|
||||
[
|
||||
{"user_id": user_id, "notification_id": notification_id}
|
||||
for notification_id in notification_ids
|
||||
]
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=["user_id", "notification_id"])
|
||||
.returning(UserNotification.id)
|
||||
)
|
||||
result = await self._session.execute(stmt)
|
||||
await self._session.flush()
|
||||
return len(list(result.scalars().all()))
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from core.logging import get_logger
|
||||
from core.auth.models import CurrentUser
|
||||
from v1.notifications.dependencies import get_notification_service
|
||||
from v1.notifications.schemas import (
|
||||
@@ -16,6 +17,7 @@ from v1.notifications.service import NotificationService
|
||||
from v1.users.dependencies import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
logger = get_logger("v1.notifications.router")
|
||||
|
||||
|
||||
@router.get("", response_model=NotificationListResponse)
|
||||
@@ -39,6 +41,13 @@ async def list_notifications(
|
||||
limit=limit,
|
||||
cursor=parsed_cursor,
|
||||
)
|
||||
logger.info(
|
||||
"Notification list fetched",
|
||||
user_id=str(current_user.id),
|
||||
limit=limit,
|
||||
item_count=len(result.items),
|
||||
has_more=result.has_more,
|
||||
)
|
||||
items = []
|
||||
for item in result.items:
|
||||
items.append(
|
||||
@@ -67,6 +76,11 @@ async def get_unread_count(
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
) -> UnreadCountResponse:
|
||||
count = await service.get_unread_count(user_id=current_user.id)
|
||||
logger.info(
|
||||
"Notification unread count fetched",
|
||||
user_id=str(current_user.id),
|
||||
count=count,
|
||||
)
|
||||
return UnreadCountResponse(count=count)
|
||||
|
||||
|
||||
@@ -95,6 +109,11 @@ async def mark_notification_read(
|
||||
user_notification_id=uid,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
logger.info(
|
||||
"Notification marked as read",
|
||||
user_id=str(current_user.id),
|
||||
user_notification_id=str(uid),
|
||||
)
|
||||
return NotificationItemResponse(
|
||||
id=str(item.id),
|
||||
notificationId=str(item.notification_id),
|
||||
@@ -114,4 +133,9 @@ async def mark_all_read(
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
) -> MarkAllReadResponse:
|
||||
updated_count = await service.mark_all_read(user_id=current_user.id)
|
||||
logger.info(
|
||||
"All notifications marked as read",
|
||||
user_id=str(current_user.id),
|
||||
updated_count=updated_count,
|
||||
)
|
||||
return MarkAllReadResponse(updatedCount=updated_count)
|
||||
|
||||
@@ -1,35 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from schemas.shared.notification import (
|
||||
NotificationPayload,
|
||||
NotificationPayloadNone,
|
||||
NotificationPayloadRoute,
|
||||
NotificationPayloadUrl,
|
||||
)
|
||||
|
||||
class NotificationPayloadNone(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
action: Literal["none"]
|
||||
|
||||
|
||||
class NotificationPayloadRoute(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
action: Literal["open_route"]
|
||||
route: str = Field(max_length=200)
|
||||
entity_id: str | None = Field(default=None, max_length=64)
|
||||
tab: str | None = Field(default=None, max_length=32)
|
||||
|
||||
|
||||
class NotificationPayloadUrl(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
action: Literal["open_url"]
|
||||
url: str = Field(max_length=500)
|
||||
|
||||
|
||||
NotificationPayload = Union[
|
||||
NotificationPayloadNone, NotificationPayloadRoute, NotificationPayloadUrl
|
||||
__all__ = [
|
||||
"NotificationPayload",
|
||||
"NotificationPayloadNone",
|
||||
"NotificationPayloadRoute",
|
||||
"NotificationPayloadUrl",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ class NotificationService:
|
||||
user_notification_id=user_notification_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
await self._repository.commit()
|
||||
payload = _parse_payload(n.payload)
|
||||
return NotificationListItem(
|
||||
id=un.id,
|
||||
@@ -117,7 +118,15 @@ class NotificationService:
|
||||
)
|
||||
|
||||
async def mark_all_read(self, *, user_id: UUID) -> int:
|
||||
return await self._repository.mark_all_read(user_id=user_id)
|
||||
updated_count = await self._repository.mark_all_read(user_id=user_id)
|
||||
if updated_count > 0:
|
||||
await self._repository.commit()
|
||||
return updated_count
|
||||
|
||||
async def link_published_notifications_to_user(self, *, user_id: UUID) -> int:
|
||||
return await self._repository.link_published_notifications_to_user(
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
|
||||
def _parse_payload(raw: dict[str, object]) -> NotificationPayload:
|
||||
|
||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter
|
||||
|
||||
from v1.agent.router import router as agent_router
|
||||
from v1.auth.router import router as auth_router
|
||||
from v1.invite.router import router as invite_router
|
||||
from v1.notifications.router import router as notifications_router
|
||||
from v1.points.router import router as points_router
|
||||
from v1.users.router import router as users_router
|
||||
@@ -12,6 +13,7 @@ from v1.users.router import router as users_router
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
router.include_router(auth_router)
|
||||
router.include_router(agent_router)
|
||||
router.include_router(invite_router)
|
||||
router.include_router(notifications_router)
|
||||
router.include_router(points_router)
|
||||
router.include_router(users_router)
|
||||
|
||||
@@ -3,9 +3,11 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.invite_code import InviteCode
|
||||
from models.points_audit_ledger import PointsAuditLedger
|
||||
from models.profile import Profile
|
||||
|
||||
|
||||
@@ -35,3 +37,28 @@ class SQLAlchemyUserRepository:
|
||||
|
||||
async def save(self) -> None:
|
||||
await self.session.commit()
|
||||
|
||||
async def delete_invite_codes_by_owner_id(self, *, user_id: UUID) -> int:
|
||||
stmt = delete(InviteCode).where(InviteCode.owner_id == user_id)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
async def delete_points_audit_snapshots(
|
||||
self,
|
||||
*,
|
||||
user_id: UUID,
|
||||
user_email: str | None,
|
||||
) -> int:
|
||||
if user_email:
|
||||
stmt = delete(PointsAuditLedger).where(
|
||||
or_(
|
||||
PointsAuditLedger.user_id_snapshot == user_id,
|
||||
PointsAuditLedger.user_email_snapshot == user_email,
|
||||
)
|
||||
)
|
||||
else:
|
||||
stmt = delete(PointsAuditLedger).where(
|
||||
PointsAuditLedger.user_id_snapshot == user_id
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.rowcount or 0)
|
||||
|
||||
@@ -296,7 +296,9 @@ class UserService:
|
||||
user_id = str(self.current_user.id)
|
||||
avatar_bucket = config.storage.avatar.bucket
|
||||
avatar_prefix = f"{self.current_user.id}/"
|
||||
points_repository = PointsRepository(self.repository.session)
|
||||
session = self.repository.session
|
||||
points_repository = PointsRepository(session) if session is not None else None
|
||||
normalized_email = (self.current_user.email or "").strip().lower() or None
|
||||
|
||||
try:
|
||||
await self.attachment_storage.delete_prefix(
|
||||
@@ -318,30 +320,51 @@ class UserService:
|
||||
),
|
||||
) from exc
|
||||
|
||||
try:
|
||||
user_email = (self.current_user.email or "").strip().lower()
|
||||
if user_email:
|
||||
email_hash = PointsService._build_register_bonus_email_hash(user_email)
|
||||
account = await points_repository.get_user_points(
|
||||
user_id=self.current_user.id
|
||||
if session is not None and points_repository is not None:
|
||||
try:
|
||||
deleted_invite_codes = (
|
||||
await self.repository.delete_invite_codes_by_owner_id(
|
||||
user_id=self.current_user.id
|
||||
)
|
||||
)
|
||||
await points_repository.update_register_bonus_balance_snapshot(
|
||||
email_hash=email_hash,
|
||||
balance_snapshot=int(account.balance),
|
||||
deleted_audit_rows = (
|
||||
await self.repository.delete_points_audit_snapshots(
|
||||
user_id=self.current_user.id,
|
||||
user_email=normalized_email,
|
||||
)
|
||||
)
|
||||
await self.repository.session.commit()
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Account deletion failed while persisting points snapshot",
|
||||
user_id=user_id,
|
||||
)
|
||||
raise ApiProblemError(
|
||||
status_code=502,
|
||||
detail=problem_payload(
|
||||
code="PROFILE_DELETE_FAILED",
|
||||
detail="Failed to delete account data",
|
||||
),
|
||||
) from exc
|
||||
|
||||
if normalized_email:
|
||||
email_hash = PointsService._build_register_bonus_email_hash(
|
||||
normalized_email
|
||||
)
|
||||
account = await points_repository.get_user_points(
|
||||
user_id=self.current_user.id
|
||||
)
|
||||
await points_repository.update_register_bonus_balance_snapshot(
|
||||
email_hash=email_hash,
|
||||
balance_snapshot=int(account.balance),
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"Account deletion local data cleanup completed",
|
||||
user_id=user_id,
|
||||
invite_codes_deleted=deleted_invite_codes,
|
||||
points_audit_rows_deleted=deleted_audit_rows,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"Account deletion failed while cleaning local data",
|
||||
user_id=user_id,
|
||||
)
|
||||
raise ApiProblemError(
|
||||
status_code=502,
|
||||
detail=problem_payload(
|
||||
code="PROFILE_DELETE_FAILED",
|
||||
detail="Failed to delete account data",
|
||||
),
|
||||
) from exc
|
||||
|
||||
try:
|
||||
await self.attachment_storage.delete_auth_user(user_id=user_id)
|
||||
|
||||
@@ -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._mark_read_ids: list[UUID] = []
|
||||
self._mark_all_read_user_ids: list[UUID] = []
|
||||
self._commit_count = 0
|
||||
|
||||
def add_item(self, un: _FakeUserNotification, n: _FakeNotification) -> None:
|
||||
self._items.append((un, n))
|
||||
@@ -129,6 +130,9 @@ class _FakeNotificationRepository:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
async def commit(self) -> None:
|
||||
self._commit_count += 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_repo() -> _FakeNotificationRepository:
|
||||
|
||||
@@ -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: `少阳 | 少阴 | 老阳 | 老阴`
|
||||
- Additional fields are forbidden.
|
||||
|
||||
### Frontend coin-face to `yaoLines` derivation rules
|
||||
|
||||
This section is normative for frontend collection flows (`手动起卦` and `自动起卦`).
|
||||
|
||||
- Both manual and auto flows MUST use the same canonical conversion logic.
|
||||
- Conversion baseline is manual flow semantics (`huaCount` baseline).
|
||||
- Auto flow (`ziCount` baseline) MUST be converted to `huaCount` before mapping.
|
||||
- Do not maintain separate mapping tables per page/screen.
|
||||
|
||||
Canonical mapping (`huaCount` -> `yaoType`):
|
||||
|
||||
- `0` -> `老阴`
|
||||
- `1` -> `少阳`
|
||||
- `2` -> `少阴`
|
||||
- `3` -> `老阳`
|
||||
|
||||
Equivalent auto mapping (`ziCount` -> `yaoType`):
|
||||
|
||||
- `0` -> `老阳`
|
||||
- `1` -> `少阴`
|
||||
- `2` -> `少阳`
|
||||
- `3` -> `老阴`
|
||||
|
||||
Implementation requirement:
|
||||
|
||||
- Frontend should centralize this conversion in one reusable converter and use it in both manual and auto screens.
|
||||
- `yaoLines` sent to backend MUST always be derived from this canonical mapping and keep order `初爻 -> 上爻`.
|
||||
|
||||
### `runtime_mode` rules
|
||||
|
||||
- Allowed values: `chat | follow_up`.
|
||||
|
||||
Reference in New Issue
Block a user