feat: integrate invite API and improve notification handling

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