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