From 8a18b3528bd5beb2ddc8bb597706ee7ee0ed2487 Mon Sep 17 00:00:00 2001 From: ZL-Q Date: Mon, 6 Apr 2026 01:28:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=94=BB=E5=83=8F=E3=80=81=E5=8D=A0=E5=8D=9C=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E4=B8=8E=E5=90=8E=E7=AB=AF=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- .opencode/commands/android-test.md | 24 + .opencode/commands/ios-test.md | 24 + .opencode/opencode.json | 10 + AGENTS.md | 32 + QQ20260406-003407.png | Bin 0 -> 140106 bytes apps/.metadata | 13 +- apps/AGENTS.md | 3 +- apps/ios/Flutter/AppFrameworkInfo.plist | 2 - apps/ios/Flutter/Debug.xcconfig | 1 + apps/ios/Flutter/Release.xcconfig | 1 + apps/ios/Podfile | 43 + apps/ios/Runner/AppDelegate.swift | 7 +- apps/ios/Runner/Info.plist | 33 +- apps/ios/Runner/SceneDelegate.swift | 6 + apps/lib/app/app.dart | 197 +++- .../auth/presentation/bloc/auth_bloc.dart | 7 - .../presentation/screens/login_screen.dart | 515 ++++++---- .../divination/data/apis/divination_api.dart | 111 ++ .../data/models/divination_params.dart | 55 + .../data/models/divination_result.dart | 150 +++ .../data/services/divination_run_service.dart | 4 + .../screens/auto_divination_screen.dart | 57 ++ .../screens/divination_processing_screen.dart | 219 +++- .../screens/divination_result_screen.dart | 582 ++++++++--- .../screens/divination_screen.dart | 31 +- .../screens/manual_divination_screen.dart | 73 +- .../presentation/screens/home_screen.dart | 269 +++-- .../settings/data/apis/profile_api.dart | 90 ++ .../data/models/profile_settings.dart | 16 + .../screens/profile_edit_screen.dart | 278 +++++ .../presentation/screens/settings_screen.dart | 92 +- .../widgets/settings_section_widgets.dart | 112 +- apps/lib/l10n/app_en.arb | 62 +- apps/lib/l10n/app_localizations.dart | 266 ++++- apps/lib/l10n/app_localizations_en.dart | 152 ++- apps/lib/l10n/app_localizations_zh.dart | 144 ++- apps/lib/l10n/app_zh.arb | 56 +- apps/lib/shared/widgets/app_modal_dialog.dart | 148 +++ .../widgets/divination/divination_terms.dart | 1 + apps/pubspec.yaml | 1 + .../divination_result_screen_test.dart | 59 +- .../divination/divination_screen_test.dart | 8 +- .../manual_divination_screen_test.dart | 6 +- backend/src/core/agentscope/events/store.py | 17 +- .../core/agentscope/prompts/agent_prompt.py | 2 +- backend/src/core/agentscope/runtime/runner.py | 10 +- .../core/agentscope/runtime/stage_emitter.py | 1 + .../src/core/divination/data/gua_catalog.json | 962 ++++++++++++++++++ .../src/core/divination/gua_catalog_loader.py | 134 +-- backend/src/models/__init__.py | 2 + backend/src/models/auth_user.py | 21 + backend/src/models/profile.py | 1 + backend/src/schemas/agent/runtime_models.py | 4 +- backend/src/v1/agent/repository.py | 53 + backend/src/v1/agent/schemas.py | 32 +- backend/src/v1/agent/service.py | 44 +- backend/src/v1/agent/utils.py | 67 +- backend/src/v1/router.py | 2 + backend/src/v1/users/dependencies.py | 8 +- backend/src/v1/users/repository.py | 33 +- backend/src/v1/users/router.py | 47 + backend/src/v1/users/schemas.py | 43 + backend/src/v1/users/service.py | 277 ++++- .../2026-04-03-datetime-picker-design.md | 76 -- docs/plans/2026-04-03-datetime-picker-impl.md | 341 ------- .../2026-04-03-user-points-chat-design.md | 505 --------- ...ion-history-profile-backend-source-plan.md | 241 +++++ ...-05-divination-history-profile-eng-plan.md | 403 ++++++++ docs/protocols/common/http-error-codes.md | 17 + .../divination/divination-run-protocol.md | 68 ++ docs/protocols/profile/profile-protocol.md | 143 +++ docs/references/backend-features.md | 366 ------- .../divination-agent-api-reference.md | 202 ---- docs/references/old-database-schema.md | 350 ------- infra/docker/supabase/docker-compose.yml | 3 +- skills-lock.json | 15 + 77 files changed, 5850 insertions(+), 2604 deletions(-) create mode 100644 .opencode/commands/android-test.md create mode 100644 .opencode/commands/ios-test.md create mode 100644 .opencode/opencode.json create mode 100644 QQ20260406-003407.png create mode 100644 apps/ios/Podfile create mode 100644 apps/ios/Runner/SceneDelegate.swift create mode 100644 apps/lib/features/settings/data/apis/profile_api.dart create mode 100644 apps/lib/features/settings/presentation/screens/profile_edit_screen.dart create mode 100644 apps/lib/shared/widgets/app_modal_dialog.dart create mode 100644 backend/src/core/divination/data/gua_catalog.json create mode 100644 backend/src/models/auth_user.py create mode 100644 backend/src/v1/users/router.py create mode 100644 backend/src/v1/users/schemas.py delete mode 100644 docs/plans/2026-04-03-datetime-picker-design.md delete mode 100644 docs/plans/2026-04-03-datetime-picker-impl.md delete mode 100644 docs/plans/2026-04-03-user-points-chat-design.md create mode 100644 docs/plans/2026-04-05-divination-history-profile-backend-source-plan.md create mode 100644 docs/plans/2026-04-05-divination-history-profile-eng-plan.md create mode 100644 docs/protocols/profile/profile-protocol.md delete mode 100644 docs/references/backend-features.md delete mode 100644 docs/references/divination-agent-api-reference.md delete mode 100644 docs/references/old-database-schema.md create mode 100644 skills-lock.json diff --git a/.gitignore b/.gitignore index 56dd8ca..03e6949 100644 --- a/.gitignore +++ b/.gitignore @@ -306,9 +306,7 @@ infra/docker/supabase/volumes/storage/ # OpenCode local config # .opencode/ is now tracked - see .opencode/.gitignore for exclusions - -# Agents and skills -.agents/ +midscene_run/ # Local git worktrees .worktrees/ diff --git a/.opencode/commands/android-test.md b/.opencode/commands/android-test.md new file mode 100644 index 0000000..5dad31f --- /dev/null +++ b/.opencode/commands/android-test.md @@ -0,0 +1,24 @@ +--- +description: Run an Android automation test through Midscene Skills +--- + +You are running an Android mobile UI automation task for this project. + +Interpret the user arguments as the exact natural-language test goal: + +$ARGUMENTS + +Execution requirements: + +1. Verify that adb is available and that at least one Android device or emulator is connected. +2. If no Android target is available, stop and report that the Android automation prerequisite is missing. +3. Use the installed Midscene Android skill workflow to execute the requested UI actions on the Android emulator or device. +4. Prefer acting on the current development build of the app when applicable. +5. Capture visible evidence during the run when useful, especially the final screen state. +6. At the end, report: + - whether the flow succeeded + - the exact failing step if any + - what was observed on screen + - what should be fixed next if this looked like a product bug + +Do not only describe a test plan. Actually perform the automation when prerequisites are available. diff --git a/.opencode/commands/ios-test.md b/.opencode/commands/ios-test.md new file mode 100644 index 0000000..8f4a255 --- /dev/null +++ b/.opencode/commands/ios-test.md @@ -0,0 +1,24 @@ +--- +description: Run an iOS automation test through Midscene Skills +--- + +You are running an iOS mobile UI automation task for this project. + +Interpret the user arguments as the exact natural-language test goal: + +$ARGUMENTS + +Execution requirements: + +1. Verify that WebDriverAgent is reachable at http://localhost:8100/status before doing any iOS action. +2. If WebDriverAgent is not ready, stop and report that the iOS automation prerequisite is missing. +3. Use the installed Midscene iOS skill workflow to execute the requested UI actions on the iOS simulator or device. +4. Prefer acting on the current development build of the app when applicable. +5. Capture visible evidence during the run when useful, especially the final screen state. +6. At the end, report: + - whether the flow succeeded + - the exact failing step if any + - what was observed on screen + - what should be fixed next if this looked like a product bug + +Do not only describe a test plan. Actually perform the automation when prerequisites are available. diff --git a/.opencode/opencode.json b/.opencode/opencode.json new file mode 100644 index 0000000..12c787f --- /dev/null +++ b/.opencode/opencode.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "supabase": { + "type": "remote", + "enabled": true, + "url": "http://localhost:8001/mcp" + } + } +} diff --git a/AGENTS.md b/AGENTS.md index 2b294e7..47ad053 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,3 +41,35 @@ Do not place backend/frontend implementation details here. ## Database Access When viewing data in the database, use `supabase mcp` tools (`supabase_execute_sql`, `supabase_list_tables`, etc.) instead of direct queries or other methods. + +## 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 diff --git a/QQ20260406-003407.png b/QQ20260406-003407.png new file mode 100644 index 0000000000000000000000000000000000000000..0fd149493d83f0dbad9c7ae9da0e4188f32bb1a5 GIT binary patch literal 140106 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%0AtrsR0$>v3NV-c|>CEy5Iez^Tm=M8wU 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 CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/apps/ios/Flutter/Debug.xcconfig b/apps/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/apps/ios/Flutter/Debug.xcconfig +++ b/apps/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/apps/ios/Flutter/Release.xcconfig b/apps/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/apps/ios/Flutter/Release.xcconfig +++ b/apps/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/apps/ios/Podfile b/apps/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/apps/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/apps/ios/Runner/AppDelegate.swift b/apps/ios/Runner/AppDelegate.swift index 6266644..c30b367 100644 --- a/apps/ios/Runner/AppDelegate.swift +++ b/apps/ios/Runner/AppDelegate.swift @@ -2,12 +2,15 @@ import Flutter import UIKit @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/apps/ios/Runner/Info.plist b/apps/ios/Runner/Info.plist index defc42d..fa67c11 100644 --- a/apps/ios/Runner/Info.plist +++ b/apps/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,33 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSPhotoLibraryUsageDescription + 需要访问您的相册以选择并上传头像 + NSPhotoLibraryAddUsageDescription + 需要将头像处理结果保存到您的相册 + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +70,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/apps/ios/Runner/SceneDelegate.swift b/apps/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/apps/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/apps/lib/app/app.dart b/apps/lib/app/app.dart index 7cce766..b615d76 100644 --- a/apps/lib/app/app.dart +++ b/apps/lib/app/app.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import '../core/auth/session_store.dart'; +import '../core/logging/logger.dart'; import '../data/network/api_client.dart'; import '../data/storage/local_kv_store.dart'; import '../features/auth/data/apis/auth_api.dart'; @@ -10,7 +11,9 @@ import '../features/auth/presentation/bloc/auth_bloc.dart'; import '../features/auth/presentation/bloc/auth_state.dart'; import '../features/auth/presentation/screens/login_screen.dart'; import '../features/divination/data/apis/divination_api.dart'; +import '../features/divination/data/models/divination_result.dart'; import '../features/home/presentation/screens/home_screen.dart'; +import '../features/settings/data/apis/profile_api.dart'; import '../features/settings/data/models/profile_settings.dart'; import '../l10n/app_localizations.dart'; import '../shared/widgets/app_loading_indicator.dart'; @@ -25,9 +28,11 @@ class EryaoApp extends StatefulWidget { } class _EryaoAppState extends State { + static final Logger _logger = getLogger('app.eryao_app'); final SessionStore _sessionStore = SessionStore(LocalKvStore()); late final AuthBloc _authBloc; late final DivinationApi _divinationApi; + late final ProfileApi _profileApi; Locale _locale = const Locale('zh'); ProfileSettingsV1 _profileSettings = ProfileSettingsV1.defaultsForLocale( const Locale('zh'), @@ -35,6 +40,11 @@ class _EryaoAppState extends State { int _creditsBalance = 0; bool _loadingCredits = false; String? _loadedCreditsUserEmail; + bool _loadingHistory = false; + String? _loadedHistoryUserEmail; + List _historyRecords = const []; + bool _loadingProfile = false; + String? _loadedProfileUserEmail; @override void initState() { @@ -48,6 +58,7 @@ class _EryaoAppState extends State { ); final authApi = AuthApi(apiClient: apiClient); _divinationApi = DivinationApi(apiClient: apiClient); + _profileApi = ProfileApi(apiClient: apiClient); final authRepository = AuthRepositoryImpl( authApi: authApi, sessionStore: _sessionStore, @@ -64,22 +75,192 @@ class _EryaoAppState extends State { return; } _loadingCredits = true; + _refreshCredits(userEmail: userEmail).whenComplete(() { + _loadingCredits = false; + }); + } + + void _ensureHistoryLoaded(String userEmail) { + if (_loadingHistory) { + return; + } + if (_loadedHistoryUserEmail == userEmail) { + return; + } + _loadingHistory = true; _divinationApi - .getPointsBalance() - .then((balance) { + .getHistoryRecords(userId: userEmail) + .then((records) { if (!mounted) { return; } setState(() { - _creditsBalance = balance.availableBalance; - _loadedCreditsUserEmail = userEmail; + _historyRecords = records; + _loadedHistoryUserEmail = userEmail; }); }) + .catchError((Object error, StackTrace stackTrace) { + _logger.warning( + message: 'Failed to load divination history', + extra: { + 'error': error.toString(), + 'stackTrace': stackTrace.toString(), + }, + ); + }) .whenComplete(() { - _loadingCredits = false; + _loadingHistory = false; }); } + Future _refreshCredits({required String userEmail}) async { + final balance = await _divinationApi.getPointsBalance(); + if (!mounted) { + return; + } + setState(() { + _creditsBalance = balance.availableBalance; + _loadedCreditsUserEmail = userEmail; + }); + } + + Future _handleDivinationCompleted(DivinationResultData result) async { + final user = _authBloc.state.user; + if (user == null) { + return; + } + + final optimisticRecords = _mergeAndSortHistory([ + result, + ..._historyRecords, + ]); + + if (!mounted) { + return; + } + + setState(() { + _historyRecords = optimisticRecords; + _loadedHistoryUserEmail = user.email; + }); + + try { + final records = await _divinationApi.getHistoryRecords( + userId: user.email, + ); + if (!mounted) { + return; + } + setState(() { + _historyRecords = _mergeAndSortHistory([ + ...records, + ...optimisticRecords, + ]); + _loadedHistoryUserEmail = user.email; + }); + } catch (error, stackTrace) { + _logger.warning( + message: 'Failed to refresh history after divination completion', + extra: { + 'error': error.toString(), + 'stackTrace': stackTrace.toString(), + }, + ); + } + + try { + await _refreshCredits(userEmail: user.email); + } catch (error, stackTrace) { + _logger.warning( + message: 'Failed to refresh credits after divination completion', + extra: { + 'error': error.toString(), + 'stackTrace': stackTrace.toString(), + }, + ); + } + } + + List _mergeAndSortHistory( + List input, + ) { + final seen = {}; + final deduped = []; + for (final item in input) { + final key = _historyKey(item); + if (seen.add(key)) { + deduped.add(item); + } + } + deduped.sort( + (a, b) => b.params.divinationTime.compareTo(a.params.divinationTime), + ); + return deduped; + } + + String _historyKey(DivinationResultData item) { + return [ + item.params.question, + item.binaryCode, + item.changedBinaryCode, + item.guaName, + item.targetGuaName, + item.signType, + ].join('|'); + } + + Future _refreshProfile({required String userEmail}) async { + if (_loadingProfile) { + return; + } + if (_loadedProfileUserEmail == userEmail) { + return; + } + _loadingProfile = true; + try { + final profile = await _profileApi.getProfile(); + if (!mounted) { + return; + } + setState(() { + _profileSettings = profile; + _loadedProfileUserEmail = userEmail; + }); + } finally { + _loadingProfile = false; + } + } + + Future _uploadAvatar(String filePath) async { + final updated = await _profileApi.uploadAvatar(filePath); + if (!mounted) { + return updated; + } + setState(() { + _profileSettings = updated; + }); + return updated; + } + + Future _saveProfileSettings(ProfileSettingsV1 next) async { + try { + final saved = await _profileApi.updateProfile(next); + if (!mounted) { + return; + } + setState(() { + _profileSettings = saved; + }); + } catch (error, stackTrace) { + _logger.error( + message: 'Failed to save profile settings via API', + error: error, + stackTrace: stackTrace, + ); + rethrow; + } + } + @override void dispose() { _authBloc.dispose(); @@ -149,13 +330,19 @@ class _EryaoAppState extends State { if (state.status == AuthStatus.authenticated && state.user != null) { _ensureCreditsLoaded(state.user!.email); + _ensureHistoryLoaded(state.user!.email); + _refreshProfile(userEmail: state.user!.email); return HomeScreen( account: state.user!.email, sessionStore: _sessionStore, currentLocale: _locale, profileSettings: _profileSettings, + historyRecords: _historyRecords, coinBalance: _creditsBalance, onLocaleChanged: _handleInterfaceLanguageChanged, + onProfileSettingsChanged: _saveProfileSettings, + onUploadAvatar: _uploadAvatar, + onDivinationCompleted: _handleDivinationCompleted, onLogout: _authBloc.logout, ); } diff --git a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart index bd5ba34..0440077 100644 --- a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -56,13 +56,9 @@ class AuthBloc extends ChangeNotifier { } Future logout() async { - Object? caughtError; - StackTrace? caughtStackTrace; try { await _repository.logout(); } catch (error, stackTrace) { - caughtError = error; - caughtStackTrace = stackTrace; _logger.error( message: 'User logout failed: ${error.runtimeType}', error: error.runtimeType.toString(), @@ -72,9 +68,6 @@ class AuthBloc extends ChangeNotifier { _logger.info(message: 'User logged out'); _state = const AuthState(status: AuthStatus.unauthenticated); notifyListeners(); - if (caughtError != null) { - Error.throwWithStackTrace(caughtError, caughtStackTrace!); - } } Future handleUnauthorized401() async { diff --git a/apps/lib/features/auth/presentation/screens/login_screen.dart b/apps/lib/features/auth/presentation/screens/login_screen.dart index 1b62b08..b7789d5 100644 --- a/apps/lib/features/auth/presentation/screens/login_screen.dart +++ b/apps/lib/features/auth/presentation/screens/login_screen.dart @@ -11,6 +11,7 @@ import '../../../settings/presentation/screens/legal_document_screen.dart'; import '../../../settings/presentation/utils/legal_document_assets.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/widgets/app_modal_dialog.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; @@ -156,17 +157,48 @@ class _LoginScreenState extends State { return l10n.errorRequestGeneric; } + InputDecoration _inputDecoration({ + required String hintText, + required IconData icon, + }) { + final colors = Theme.of(context).colorScheme; + return InputDecoration( + hintText: hintText, + filled: true, + fillColor: colors.surface.withValues(alpha: 0.92), + prefixIcon: Icon(icon, color: colors.primary), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + borderSide: BorderSide(color: colors.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + borderSide: BorderSide(color: colors.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.lg), + borderSide: BorderSide(color: colors.primary, width: 1.6), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.lg, + ), + ); + } + void _showPolicyDialog(String title, String content) { showDialog( context: context, - builder: (context) { - return AlertDialog( - title: Text(title), - content: Text(content), + builder: (dialogContext) { + return AppModalDialog( + title: title, + message: content, + icon: Icons.description_outlined, actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(AppLocalizations.of(context)!.dialogConfirm), + AppModalDialogAction( + label: AppLocalizations.of(dialogContext)!.dialogConfirm, + primary: true, + onPressed: () => Navigator.of(dialogContext).pop(), ), ], ); @@ -197,214 +229,271 @@ class _LoginScreenState extends State { _isValidEmail && _codeController.text.length == 6 && _agreementChecked; return Scaffold( - body: GestureDetector( - onTap: () => FocusScope.of(context).unfocus(), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.xl, - vertical: AppSpacing.lg, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: AppSpacing.xxl), - Container( - width: double.infinity, - padding: const EdgeInsets.all(AppSpacing.xl), - decoration: BoxDecoration( - color: colors.surface, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - l10n.welcomeLogin, - style: Theme.of(context).textTheme.headlineMedium, - ), - const SizedBox(height: AppSpacing.sm), - Text( - l10n.loginSubtitleEmail, - style: Theme.of(context).textTheme.bodyLarge, - ), - ], - ), - ), - const SizedBox(height: AppSpacing.xxl), - Container( - decoration: BoxDecoration( - color: colors.surface, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: TextField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - hintText: l10n.emailHint, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppRadius.lg), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - vertical: AppSpacing.lg, - ), - ), - ), - ), - const SizedBox(height: AppSpacing.lg), - Row( - children: [ - Expanded( - child: Container( - decoration: BoxDecoration( - color: colors.surface, - borderRadius: BorderRadius.circular(AppRadius.lg), - ), - child: TextField( - controller: _codeController, - keyboardType: TextInputType.number, - maxLength: 6, - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - counterText: '', - hintText: l10n.codeHint, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppRadius.lg), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - vertical: AppSpacing.lg, - ), - ), - ), - ), - ), - const SizedBox(width: AppSpacing.sm), - SizedBox( - width: 130, - height: 48, - child: FilledButton( - style: FilledButton.styleFrom( - backgroundColor: colors.surfaceContainerHighest, - foregroundColor: colors.primary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.full), - ), - ), - onPressed: _sendCode, - child: Text( - _isSending - ? l10n.sending - : _countdown > 0 - ? l10n.retryAfter(_countdown) - : l10n.sendCode, - ), - ), - ), - ], - ), - const SizedBox(height: AppSpacing.xl), - SizedBox( - width: double.infinity, - child: FilledButton( - style: FilledButton.styleFrom( - backgroundColor: colors.primary, - foregroundColor: colors.onPrimary, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.full), - ), - padding: const EdgeInsets.symmetric( - vertical: AppSpacing.md, - ), - ), - onPressed: canLogin ? _login : null, - child: Text( - l10n.login, - style: const TextStyle(fontSize: 16), - ), - ), - ), - const SizedBox(height: AppSpacing.md), - Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Checkbox( - value: _agreementChecked, - onChanged: (value) { - setState(() { - _agreementChecked = value ?? false; - }); - }, - ), - Flexible( - child: RichText( - text: TextSpan( - style: Theme.of(context).textTheme.bodySmall, - children: [ - TextSpan(text: l10n.agreementPrefix), - TextSpan( - text: l10n.privacyPolicy, - style: TextStyle( - color: colors.primary, - decoration: TextDecoration.underline, - ), - recognizer: TapGestureRecognizer() - ..onTap = () => _openLegalDocument( - LegalDocumentType.privacyPolicy, - ), - ), - TextSpan(text: l10n.agreementSeparator), - TextSpan( - text: l10n.termsOfService, - style: TextStyle( - color: colors.primary, - decoration: TextDecoration.underline, - ), - recognizer: TapGestureRecognizer() - ..onTap = () => _openLegalDocument( - LegalDocumentType.termsOfService, - ), - ), - TextSpan(text: l10n.agreementAnd), - TextSpan( - text: l10n.disclaimer, - style: TextStyle( - color: colors.primary, - decoration: TextDecoration.underline, - ), - recognizer: TapGestureRecognizer() - ..onTap = () => _showPolicyDialog( - l10n.disclaimer, - l10n.disclaimerContent, - ), - ), - ], - ), - ), - ), - ], - ), - ), - const Spacer(), - Center( - child: Text( - l10n.icp, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colors.primary, - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(height: AppSpacing.sm), - ], - ), + resizeToAvoidBottomInset: true, + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colors.secondaryContainer.withValues(alpha: 0.55), + colors.primaryContainer.withValues(alpha: 0.42), + colors.surfaceContainerLow, + ], ), ), + child: Stack( + children: [ + Positioned( + top: -86, + right: -42, + child: Container( + width: 180, + height: 180, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colors.primary.withValues(alpha: 0.1), + ), + ), + ), + Positioned( + bottom: -110, + left: -34, + child: Container( + width: 210, + height: 210, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colors.secondary.withValues(alpha: 0.08), + ), + ), + ), + GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final bottomInset = MediaQuery.of( + context, + ).viewInsets.bottom; + return SingleChildScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + padding: EdgeInsets.fromLTRB( + AppSpacing.xl, + AppSpacing.lg, + AppSpacing.xl, + AppSpacing.lg + bottomInset, + ), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: AppSpacing.xxxl), + Center( + child: Column( + children: [ + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + color: colors.surface.withValues( + alpha: 0.9, + ), + borderRadius: BorderRadius.circular( + AppRadius.full, + ), + border: Border.all( + color: colors.primary.withValues( + alpha: 0.2, + ), + ), + ), + padding: const EdgeInsets.all( + AppSpacing.md, + ), + child: Image.asset( + 'assets/images/logo.png', + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + l10n.appTitle, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.xxxl), + TextField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + onChanged: (_) => setState(() {}), + decoration: _inputDecoration( + hintText: l10n.emailHint, + icon: Icons.alternate_email, + ), + ), + const SizedBox(height: AppSpacing.lg), + Row( + children: [ + Expanded( + child: TextField( + controller: _codeController, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + maxLength: 6, + onChanged: (_) => setState(() {}), + decoration: _inputDecoration( + hintText: l10n.codeHint, + icon: Icons.lock_outline, + ).copyWith(counterText: ''), + ), + ), + const SizedBox(width: AppSpacing.sm), + SizedBox( + width: 128, + height: 52, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: colors.primary, + foregroundColor: colors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppRadius.full, + ), + ), + ), + onPressed: _sendCode, + child: Text( + _isSending + ? l10n.sending + : _countdown > 0 + ? l10n.retryAfter(_countdown) + : l10n.sendCode, + textAlign: TextAlign.center, + ), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.xl), + SizedBox( + width: double.infinity, + child: FilledButton( + style: FilledButton.styleFrom( + backgroundColor: colors.primary, + foregroundColor: colors.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppRadius.full, + ), + ), + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.md, + ), + ), + onPressed: canLogin ? _login : null, + child: Text( + l10n.login, + style: const TextStyle(fontSize: 16), + ), + ), + ), + const SizedBox(height: AppSpacing.md), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Checkbox( + value: _agreementChecked, + onChanged: (value) { + setState(() { + _agreementChecked = value ?? false; + }); + }, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only( + top: AppSpacing.sm, + ), + child: RichText( + text: TextSpan( + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: colors.onSurface), + children: [ + TextSpan(text: l10n.agreementPrefix), + TextSpan( + text: l10n.privacyPolicy, + style: TextStyle( + color: colors.primary, + decoration: + TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => + _openLegalDocument( + LegalDocumentType + .privacyPolicy, + ), + ), + TextSpan( + text: l10n.agreementSeparator, + ), + TextSpan( + text: l10n.termsOfService, + style: TextStyle( + color: colors.primary, + decoration: + TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => + _openLegalDocument( + LegalDocumentType + .termsOfService, + ), + ), + TextSpan(text: l10n.agreementAnd), + TextSpan( + text: l10n.disclaimer, + style: TextStyle( + color: colors.primary, + decoration: + TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => _showPolicyDialog( + l10n.disclaimer, + l10n.disclaimerContent, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ), + ), + ), + ], + ), ), ); } diff --git a/apps/lib/features/divination/data/apis/divination_api.dart b/apps/lib/features/divination/data/apis/divination_api.dart index 7c24d52..4fdc47e 100644 --- a/apps/lib/features/divination/data/apis/divination_api.dart +++ b/apps/lib/features/divination/data/apis/divination_api.dart @@ -8,6 +8,7 @@ import '../../../../core/network/api_problem.dart'; import '../../../../data/network/api_client.dart'; import '../models/divination_backend_models.dart'; import '../models/divination_params.dart'; +import '../models/divination_result.dart'; class DivinationApi { const DivinationApi({required ApiClient apiClient}) : _apiClient = apiClient; @@ -37,6 +38,67 @@ class DivinationApi { return RunAcceptedData.fromJson(json); } + Future> getHistoryRecords({ + required String userId, + }) async { + final json = await _apiClient.getJson('/api/v1/agent/history'); + final messagesRaw = json['messages']; + if (messagesRaw is! List) { + return const []; + } + + final records = []; + for (final raw in messagesRaw) { + if (raw is! Map) { + continue; + } + if (raw['role'] != 'assistant') { + continue; + } + final agentOutputRaw = raw['agent_output']; + if (agentOutputRaw is! Map) { + continue; + } + final derivedRaw = agentOutputRaw['divination_derived']; + if (derivedRaw is! Map) { + continue; + } + try { + final derived = DerivedDivinationData.fromJson(derivedRaw); + final divinationTime = _resolveHistoryTime(raw, derived); + final params = DivinationParams( + method: _methodFromText(derived.divinationMethod), + questionType: _questionTypeFromText(derived.questionType), + question: derived.question, + divinationTime: divinationTime, + coinBalance: 0, + userId: userId, + ); + final aggregate = DivinationRunAggregate( + derived: derived, + signLevel: _asString(agentOutputRaw['sign_level']), + summary: _asString(agentOutputRaw['summary']), + conclusion: _asStringList(agentOutputRaw['conclusion']), + focusPoints: _asStringList(agentOutputRaw['focus_points']), + advice: _asStringList(agentOutputRaw['advice']), + keywords: _asStringList(agentOutputRaw['keywords']), + answer: _asString(agentOutputRaw['answer']), + ); + records.add(aggregate.toViewData(params)); + } catch (error, stackTrace) { + _logger.warning( + message: 'Skip malformed history assistant message', + extra: { + 'error': error.toString(), + 'stackTrace': stackTrace.toString(), + }, + ); + continue; + } + } + return records; + } + Stream> streamEvents({ required String threadId, required String runId, @@ -217,6 +279,55 @@ String _questionTypeToText(QuestionType type) { }; } +QuestionType _questionTypeFromText(String raw) { + return switch (raw) { + '事业' => QuestionType.career, + '情感' => QuestionType.love, + '财富' => QuestionType.wealth, + '运势' => QuestionType.fortune, + '解梦' => QuestionType.dream, + '健康' => QuestionType.health, + '学业' => QuestionType.study, + '寻物' => QuestionType.search, + _ => QuestionType.other, + }; +} + +DivinationMethod _methodFromText(String raw) { + return raw == '自动起卦' ? DivinationMethod.auto : DivinationMethod.manual; +} + +DateTime _resolveHistoryTime( + Map message, + DerivedDivinationData derived, +) { + final timestamp = message['timestamp']; + if (timestamp is String) { + final parsed = DateTime.tryParse(timestamp); + if (parsed != null) { + return parsed.toLocal(); + } + } + + final derivedTime = DateTime.tryParse(derived.divinationTime); + if (derivedTime != null) { + return derivedTime.toLocal(); + } + + return DateTime.now(); +} + +String _asString(Object? value) { + return value is String ? value : ''; +} + +List _asStringList(Object? value) { + if (value is! List) { + return const []; + } + return value.whereType().toList(growable: false); +} + String _yaoTypeToText(YaoType type) { return switch (type) { YaoType.youngYang => '少阳', diff --git a/apps/lib/features/divination/data/models/divination_params.dart b/apps/lib/features/divination/data/models/divination_params.dart index 29ce8db..81e726c 100644 --- a/apps/lib/features/divination/data/models/divination_params.dart +++ b/apps/lib/features/divination/data/models/divination_params.dart @@ -60,6 +60,21 @@ class DivinationParams { }; } + factory DivinationParams.fromPayload(Map payload) { + return DivinationParams( + method: divinationMethodFromName(_requiredString(payload, 'method')), + questionType: questionTypeFromName( + _requiredString(payload, 'questionType'), + ), + question: _requiredString(payload, 'question'), + divinationTime: DateTime.parse( + _requiredString(payload, 'divinationTime'), + ), + coinBalance: _requiredInt(payload, 'coinBalance'), + userId: _requiredString(payload, 'userId'), + ); + } + String toBinary(List yaoStates) { return yaoStates .map( @@ -85,3 +100,43 @@ class DivinationParams { .join(); } } + +DivinationMethod divinationMethodFromName(String raw) { + return DivinationMethod.values.firstWhere( + (value) => value.name == raw, + orElse: () => DivinationMethod.manual, + ); +} + +QuestionType questionTypeFromName(String raw) { + return QuestionType.values.firstWhere( + (value) => value.name == raw, + orElse: () => QuestionType.other, + ); +} + +YaoType yaoTypeFromName(String raw) { + return YaoType.values.firstWhere( + (value) => value.name == raw, + orElse: () => YaoType.undetermined, + ); +} + +String _requiredString(Map json, String key) { + final value = json[key]; + if (value is! String || value.isEmpty) { + throw FormatException('Missing required string: $key'); + } + return value; +} + +int _requiredInt(Map json, String key) { + final value = json[key]; + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + throw FormatException('Missing required int: $key'); +} diff --git a/apps/lib/features/divination/data/models/divination_result.dart b/apps/lib/features/divination/data/models/divination_result.dart index 6d435eb..ec24209 100644 --- a/apps/lib/features/divination/data/models/divination_result.dart +++ b/apps/lib/features/divination/data/models/divination_result.dart @@ -38,6 +38,79 @@ class DivinationResultData { final List targetYaoLines; bool get hasChangingYao => binaryCode != changedBinaryCode; + + Map toJson() { + return { + 'params': params.toPayload(), + 'binaryCode': binaryCode, + 'changedBinaryCode': changedBinaryCode, + 'guaName': guaName, + 'targetGuaName': targetGuaName, + 'upperName': upperName, + 'lowerName': lowerName, + 'signType': signType, + 'keywords': keywords, + 'conclusion': conclusion, + 'analysis': analysis, + 'suggestion': suggestion, + 'ganzhi': ganzhi.toJson(), + 'wuXingStatus': wuXingStatus, + 'yaoLines': yaoLines.map((line) => line.toJson()).toList(growable: false), + 'targetYaoLines': targetYaoLines + .map((line) => line.toJson()) + .toList(growable: false), + }; + } + + factory DivinationResultData.fromJson(Map json) { + final paramsRaw = json['params']; + final ganzhiRaw = json['ganzhi']; + final wuXingRaw = json['wuXingStatus']; + final yaoLinesRaw = json['yaoLines']; + final targetYaoLinesRaw = json['targetYaoLines']; + if (paramsRaw is! Map || + ganzhiRaw is! Map || + wuXingRaw is! Map || + yaoLinesRaw is! List || + targetYaoLinesRaw is! List) { + throw const FormatException('Invalid divination result payload'); + } + + return DivinationResultData( + params: DivinationParams.fromPayload(paramsRaw), + binaryCode: _requiredString(json, 'binaryCode'), + changedBinaryCode: _requiredString(json, 'changedBinaryCode'), + guaName: _requiredString(json, 'guaName'), + targetGuaName: _requiredString(json, 'targetGuaName'), + upperName: _requiredString(json, 'upperName'), + lowerName: _requiredString(json, 'lowerName'), + signType: _requiredString(json, 'signType'), + keywords: _requiredString(json, 'keywords'), + conclusion: _requiredString(json, 'conclusion'), + analysis: _requiredString(json, 'analysis'), + suggestion: _requiredString(json, 'suggestion'), + ganzhi: GanzhiData.fromJson(ganzhiRaw), + wuXingStatus: wuXingRaw.map( + (key, value) => MapEntry(key, value.toString()), + ), + yaoLines: yaoLinesRaw + .map((raw) { + if (raw is! Map) { + throw const FormatException('Invalid yao line payload'); + } + return YaoLineData.fromJson(raw); + }) + .toList(growable: false), + targetYaoLines: targetYaoLinesRaw + .map((raw) { + if (raw is! Map) { + throw const FormatException('Invalid target yao line payload'); + } + return YaoLineData.fromJson(raw); + }) + .toList(growable: false), + ); + } } class GanzhiData { @@ -68,6 +141,40 @@ class GanzhiData { final String riChen; final String yuePo; final String riChong; + + Map toJson() { + return { + 'yearGanZhi': yearGanZhi, + 'monthGanZhi': monthGanZhi, + 'dayGanZhi': dayGanZhi, + 'timeGanZhi': timeGanZhi, + 'yearKongWang': yearKongWang, + 'monthKongWang': monthKongWang, + 'dayKongWang': dayKongWang, + 'timeKongWang': timeKongWang, + 'yueJian': yueJian, + 'riChen': riChen, + 'yuePo': yuePo, + 'riChong': riChong, + }; + } + + factory GanzhiData.fromJson(Map json) { + return GanzhiData( + yearGanZhi: _requiredString(json, 'yearGanZhi'), + monthGanZhi: _requiredString(json, 'monthGanZhi'), + dayGanZhi: _requiredString(json, 'dayGanZhi'), + timeGanZhi: _requiredString(json, 'timeGanZhi'), + yearKongWang: _requiredString(json, 'yearKongWang'), + monthKongWang: _requiredString(json, 'monthKongWang'), + dayKongWang: _requiredString(json, 'dayKongWang'), + timeKongWang: _requiredString(json, 'timeKongWang'), + yueJian: _requiredString(json, 'yueJian'), + riChen: _requiredString(json, 'riChen'), + yuePo: _requiredString(json, 'yuePo'), + riChong: _requiredString(json, 'riChong'), + ); + } } class YaoLineData { @@ -88,4 +195,47 @@ class YaoLineData { final String element; final YaoType type; final String mark; + + Map toJson() { + return { + 'index': index, + 'spirit': spirit, + 'relation': relation, + 'branch': branch, + 'element': element, + 'type': type.name, + 'mark': mark, + }; + } + + factory YaoLineData.fromJson(Map json) { + return YaoLineData( + index: _requiredInt(json, 'index'), + spirit: _requiredString(json, 'spirit'), + relation: _requiredString(json, 'relation'), + branch: _requiredString(json, 'branch'), + element: _requiredString(json, 'element'), + type: yaoTypeFromName(_requiredString(json, 'type')), + mark: _requiredString(json, 'mark'), + ); + } +} + +String _requiredString(Map json, String key) { + final value = json[key]; + if (value is! String || value.isEmpty) { + throw FormatException('Missing required string: $key'); + } + return value; +} + +int _requiredInt(Map json, String key) { + final value = json[key]; + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + throw FormatException('Missing required int: $key'); } diff --git a/apps/lib/features/divination/data/services/divination_run_service.dart b/apps/lib/features/divination/data/services/divination_run_service.dart index 8e6d139..6b71365 100644 --- a/apps/lib/features/divination/data/services/divination_run_service.dart +++ b/apps/lib/features/divination/data/services/divination_run_service.dart @@ -12,6 +12,10 @@ class DivinationRunService { final DivinationApi _api; static final Logger _logger = getLogger('features.divination.run_service'); + Future getPointsBalance() { + return _api.getPointsBalance(); + } + Future run({ required DivinationParams params, required List yaoStates, diff --git a/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart b/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart index c78f3b4..171a1b2 100644 --- a/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart +++ b/apps/lib/features/divination/presentation/screens/auto_divination_screen.dart @@ -9,12 +9,17 @@ import 'package:vibration/vibration.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/widgets/app_modal_dialog.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'; +import '../../../../shared/widgets/toast/toast_type.dart'; +import '../../data/models/divination_backend_models.dart'; import '../../data/models/divination_params.dart'; +import '../../data/models/divination_result.dart'; import '../../data/services/divination_run_service.dart'; import 'divination_processing_screen.dart'; @@ -23,10 +28,12 @@ class AutoDivinationScreen extends StatefulWidget { super.key, required this.params, required this.runService, + required this.onCompleted, }); final DivinationParams params; final DivinationRunService runService; + final Future Function(DivinationResultData result) onCompleted; @override State createState() => _AutoDivinationScreenState(); @@ -216,6 +223,55 @@ class _AutoDivinationScreenState extends State } Future _submitRun() async { + final l10n = AppLocalizations.of(context)!; + PointsBalanceData points; + try { + points = await widget.runService.getPointsBalance(); + } catch (_) { + if (!mounted) { + return; + } + Toast.show(context, l10n.errorRequestGeneric, type: ToastType.error); + return; + } + if (!points.canRun || points.availableBalance < points.runCost) { + if (!mounted) { + return; + } + Toast.show(context, l10n.toastCoinInsufficient, type: ToastType.warning); + return; + } + if (!mounted) { + return; + } + final shouldStart = await showDialog( + context: context, + builder: (dialogContext) { + return AppModalDialog( + title: l10n.divinationCostDialogTitle, + message: l10n.divinationCostDialogBody( + points.runCost, + points.availableBalance, + ), + icon: Icons.auto_awesome_rounded, + actions: [ + AppModalDialogAction( + label: l10n.cancel, + onPressed: () => Navigator.of(dialogContext).pop(false), + ), + AppModalDialogAction( + label: l10n.divinationCostDialogConfirm, + primary: true, + onPressed: () => Navigator.of(dialogContext).pop(true), + ), + ], + ); + }, + ); + if (shouldStart != true) { + return; + } + setState(() { _submitting = true; }); @@ -229,6 +285,7 @@ class _AutoDivinationScreenState extends State params: widget.params.copyWith(divinationTime: _selectedTime), yaoStates: _yaoStates, runService: widget.runService, + onCompleted: widget.onCompleted, ), ), ); diff --git a/apps/lib/features/divination/presentation/screens/divination_processing_screen.dart b/apps/lib/features/divination/presentation/screens/divination_processing_screen.dart index a4c117e..17c2577 100644 --- a/apps/lib/features/divination/presentation/screens/divination_processing_screen.dart +++ b/apps/lib/features/divination/presentation/screens/divination_processing_screen.dart @@ -20,32 +20,57 @@ class DivinationProcessingScreen extends StatefulWidget { required this.params, required this.yaoStates, required this.runService, + required this.onCompleted, }); final DivinationParams params; final List yaoStates; final DivinationRunService runService; + final Future Function(DivinationResultData result) onCompleted; @override State createState() => _DivinationProcessingScreenState(); } -class _DivinationProcessingScreenState - extends State { +class _DivinationProcessingScreenState extends State + with TickerProviderStateMixin { static final Logger _logger = getLogger( 'features.divination.processing_screen', ); + static const int _iChingCardCount = 8; _ProcessingStep _step = _ProcessingStep.preparing; DivinationResultData? _resultData; String? _errorMessage; + late final AnimationController _cardRotationController; + int _currentCardIndex = 0; @override void initState() { super.initState(); + _cardRotationController = + AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2000), + )..addStatusListener((status) { + if (status != AnimationStatus.completed || !mounted) { + return; + } + setState(() { + _currentCardIndex = (_currentCardIndex + 1) % _iChingCardCount; + }); + _cardRotationController.forward(from: 0); + }); + _cardRotationController.forward(); _startRun(); } + @override + void dispose() { + _cardRotationController.dispose(); + super.dispose(); + } + Future _startRun() async { try { final aggregate = await widget.runService.run( @@ -75,6 +100,22 @@ class _DivinationProcessingScreenState _resultData = aggregate.toViewData(widget.params); _step = _ProcessingStep.done; }); + _cardRotationController.stop(); + final data = _resultData; + if (data != null) { + try { + await widget.onCompleted(data); + } catch (error, stackTrace) { + _logger.warning( + message: 'Failed to persist post-run side effects', + extra: {'error': error.toString()}, + ); + _logger.debug( + message: 'Post-run side effect stack trace', + extra: {'stackTrace': stackTrace.toString()}, + ); + } + } } catch (error, stackTrace) { _logger.error( message: 'Divination processing failed while waiting result events', @@ -117,11 +158,12 @@ class _DivinationProcessingScreenState final l10n = AppLocalizations.of(context)!; final colors = Theme.of(context).colorScheme; - final text = switch (_step) { + final statusText = switch (_step) { _ProcessingStep.preparing => l10n.transitionPreparing, _ProcessingStep.deriving => l10n.transitionDeriving, _ProcessingStep.done => l10n.transitionDone, }; + final cardDataList = _iChingCardData(l10n); final canContinue = _step == _ProcessingStep.done && _resultData != null; @@ -134,39 +176,123 @@ class _DivinationProcessingScreenState child: _errorMessage == null ? GestureDetector( onTap: canContinue ? _openResult : null, - child: Container( - width: 220, - height: 320, - decoration: BoxDecoration( - color: colors.surface, - borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all( - color: colors.primary.withValues(alpha: 0.2), - ), - boxShadow: [ - BoxShadow( - color: colors.shadow.withValues(alpha: 0.25), - blurRadius: 22, - offset: const Offset(0, 12), + child: AnimatedBuilder( + animation: _cardRotationController, + builder: (context, _) { + final angle = canContinue + ? 0.0 + : _rotationForProgress( + _cardRotationController.value, + ); + final card = cardDataList[_currentCardIndex]; + return Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..setEntry(3, 2, 0.0011) + ..rotateY(angle), + child: Container( + width: 220, + height: 320, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + colors.primaryContainer.withValues( + alpha: 0.55, + ), + colors.secondaryContainer.withValues( + alpha: 0.38, + ), + colors.surface, + ], + ), + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all( + color: colors.primary.withValues(alpha: 0.3), + ), + boxShadow: [ + BoxShadow( + color: colors.shadow.withValues(alpha: 0.18), + blurRadius: 26, + offset: const Offset(0, 14), + ), + ], + ), + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (canContinue) + Icon( + Icons.visibility, + color: colors.primary, + size: 34, + ) + else ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: colors.surface.withValues( + alpha: 0.75, + ), + borderRadius: BorderRadius.circular( + AppRadius.full, + ), + ), + child: Text( + 'I Ching', + style: Theme.of(context) + .textTheme + .labelSmall + ?.copyWith( + color: colors.primary, + letterSpacing: 0.3, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + card.$1, + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + card.$2, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + height: 1.5, + color: colors.onSurface.withValues( + alpha: 0.86, + ), + ), + ), + ], + const SizedBox(height: AppSpacing.lg), + Text( + statusText, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.titleMedium, + ), + ], + ), ), - ], - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - canContinue ? Icons.visibility : Icons.auto_awesome, - color: colors.primary, - size: 34, - ), - const SizedBox(height: AppSpacing.md), - Text( - text, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), + ); + }, ), ) : Text( @@ -181,4 +307,27 @@ class _DivinationProcessingScreenState ), ); } + + double _rotationForProgress(double progress) { + if (progress < 0.25) { + return (1 - progress / 0.25) * (3.1415926 / 2); + } + if (progress < 0.75) { + return 0; + } + return ((progress - 0.75) / 0.25) * (3.1415926 / 2); + } + + List<(String, String)> _iChingCardData(AppLocalizations l10n) { + return <(String, String)>[ + (l10n.processingCardQianTitle, l10n.processingCardQianQuote), + (l10n.processingCardDuiTitle, l10n.processingCardDuiQuote), + (l10n.processingCardLiTitle, l10n.processingCardLiQuote), + (l10n.processingCardZhenTitle, l10n.processingCardZhenQuote), + (l10n.processingCardXunTitle, l10n.processingCardXunQuote), + (l10n.processingCardKanTitle, l10n.processingCardKanQuote), + (l10n.processingCardGenTitle, l10n.processingCardGenQuote), + (l10n.processingCardKunTitle, l10n.processingCardKunQuote), + ]; + } } diff --git a/apps/lib/features/divination/presentation/screens/divination_result_screen.dart b/apps/lib/features/divination/presentation/screens/divination_result_screen.dart index 62de036..3da6d78 100644 --- a/apps/lib/features/divination/presentation/screens/divination_result_screen.dart +++ b/apps/lib/features/divination/presentation/screens/divination_result_screen.dart @@ -27,22 +27,73 @@ class DivinationResultScreen extends StatefulWidget { class _DivinationResultScreenState extends State { bool _showIntro = true; bool _introCollapsed = false; + Rect? _introTargetRect; + final GlobalKey _stackKey = GlobalKey(); + final GlobalKey _finalSignCardKey = GlobalKey(); + + void _backToHome() { + final navigator = Navigator.of(context); + navigator.popUntil((route) => route.isFirst); + } @override void initState() { super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _prepareIntro(); + }); + } + + Future _prepareIntro() async { + for (int i = 0; i < 12; i++) { + if (!mounted) { + return; + } + if (_measureIntroTargetRect()) { + break; + } + await Future.delayed(const Duration(milliseconds: 16)); + } + if (!mounted) { + return; + } _playIntro(); } + bool _measureIntroTargetRect() { + final stackContext = _stackKey.currentContext; + final targetContext = _finalSignCardKey.currentContext; + if (stackContext == null || targetContext == null) { + return false; + } + final stackRender = stackContext.findRenderObject(); + final targetRender = targetContext.findRenderObject(); + if (stackRender is! RenderBox || targetRender is! RenderBox) { + return false; + } + final offset = targetRender.localToGlobal( + Offset.zero, + ancestor: stackRender, + ); + final targetRect = offset & targetRender.size; + if (_introTargetRect == targetRect) { + return true; + } + setState(() { + _introTargetRect = targetRect; + }); + return true; + } + Future _playIntro() async { - await Future.delayed(const Duration(milliseconds: 120)); + await Future.delayed(const Duration(milliseconds: 180)); if (!mounted) { return; } setState(() { _introCollapsed = true; }); - await Future.delayed(const Duration(milliseconds: 760)); + await Future.delayed(const Duration(milliseconds: 1450)); if (!mounted) { return; } @@ -51,121 +102,179 @@ class _DivinationResultScreenState extends State { }); } + Rect _introStartRect(Size size) { + const startWidth = 332.0; + const startHeight = 234.0; + return Rect.fromLTWH( + (size.width - startWidth) / 2, + (size.height - startHeight) / 2, + startWidth, + startHeight, + ); + } + @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final palette = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context)!; - return Scaffold( - backgroundColor: colors.surface, - appBar: AppBar( + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + if (didPop) { + return; + } + _backToHome(); + }, + child: Scaffold( backgroundColor: colors.surface, - surfaceTintColor: colors.surface, - title: Text(l10n.resultScreenTitle), - centerTitle: true, - ), - body: Stack( - children: [ - AnimatedOpacity( - opacity: _showIntro ? 0 : 1, - duration: const Duration(milliseconds: 260), - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB( - AppSpacing.xl, - AppSpacing.lg, - AppSpacing.xl, - AppSpacing.xl, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _ResultHeader(data: widget.data), - const SizedBox(height: AppSpacing.md), - _SignCard(signType: widget.data.signType), - const SizedBox(height: AppSpacing.md), - _KeywordCard(keywords: widget.data.keywords), - const SizedBox(height: AppSpacing.md), - _AnalysisCard( - title: l10n.resultConclusion, - content: widget.data.conclusion, - ), - const SizedBox(height: AppSpacing.md), - _AnalysisCard( - title: l10n.resultAnalysis, - content: widget.data.analysis, - ), - const SizedBox(height: AppSpacing.md), - _AnalysisCard( - title: l10n.resultSuggestion, - content: widget.data.suggestion, - ), - const SizedBox(height: AppSpacing.md), - Container( - width: double.infinity, - padding: const EdgeInsets.all(AppSpacing.md), - decoration: BoxDecoration( - color: palette.warningContainer, - borderRadius: BorderRadius.circular(AppRadius.md), + appBar: AppBar( + leading: IconButton( + onPressed: _backToHome, + icon: const Icon(Icons.arrow_back_ios_new_rounded), + ), + backgroundColor: colors.surface, + surfaceTintColor: colors.surface, + title: Text(l10n.resultScreenTitle), + centerTitle: true, + ), + body: LayoutBuilder( + builder: (context, constraints) { + final stackSize = Size(constraints.maxWidth, constraints.maxHeight); + final startRect = _introStartRect(stackSize); + final targetRect = _introTargetRect ?? startRect; + final currentRect = _introCollapsed ? targetRect : startRect; + + return Stack( + key: _stackKey, + children: [ + AnimatedOpacity( + opacity: _showIntro ? 0 : 1, + duration: const Duration(milliseconds: 260), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xl, + AppSpacing.lg, + AppSpacing.xl, + AppSpacing.xl, ), - child: Row( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.warning, color: palette.warning, size: 20), - const SizedBox(width: AppSpacing.sm), - Expanded( - child: Text( - l10n.resultWarning, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith( - color: palette.warning, - fontWeight: FontWeight.w600, - height: 1.35, + _ResultHeader(data: widget.data), + const SizedBox(height: AppSpacing.md), + _SignCard( + key: _finalSignCardKey, + signType: widget.data.signType, + ), + const SizedBox(height: AppSpacing.md), + _KeywordCard(keywords: widget.data.keywords), + const SizedBox(height: AppSpacing.md), + _AnalysisCard( + title: l10n.resultConclusion, + content: widget.data.conclusion, + ), + const SizedBox(height: AppSpacing.md), + _AnalysisCard( + title: l10n.resultAnalysis, + content: widget.data.analysis, + ), + const SizedBox(height: AppSpacing.md), + _AnalysisCard( + title: l10n.resultSuggestion, + content: widget.data.suggestion, + ), + const SizedBox(height: AppSpacing.md), + Container( + width: double.infinity, + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: palette.warningContainer, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.warning, + color: palette.warning, + size: 20, + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + l10n.resultWarning, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: palette.warning, + fontWeight: FontWeight.w600, + height: 1.35, + ), ), + ), + ], ), ), + const SizedBox(height: AppSpacing.xl), + Text( + l10n.resultBasicInfo, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: AppSpacing.md), + _InfoCard(data: widget.data), + const SizedBox(height: AppSpacing.xl), + Text( + l10n.resultHexagramDetail, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: AppSpacing.md), + _HexagramDetailCard(data: widget.data), ], ), ), - const SizedBox(height: AppSpacing.xl), - Text( - l10n.resultBasicInfo, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: AppSpacing.md), - _InfoCard(data: widget.data), - const SizedBox(height: AppSpacing.xl), - Text( - l10n.resultHexagramDetail, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: AppSpacing.md), - _HexagramDetailCard(data: widget.data), - ], - ), - ), - ), - if (_showIntro) - Positioned.fill( - child: Material( - color: colors.surface, - child: SafeArea( - child: AnimatedAlign( - duration: const Duration(milliseconds: 760), - curve: Curves.easeInOutCubic, - alignment: _introCollapsed - ? const Alignment(0, -0.86) - : Alignment.center, - child: AnimatedContainer( - duration: const Duration(milliseconds: 760), - curve: Curves.easeInOutCubic, - width: _introCollapsed ? 150 : 290, - child: _SignCard(signType: widget.data.signType), + ), + if (_showIntro) + Positioned.fill( + child: IgnorePointer( + child: ColoredBox(color: colors.surface), ), ), - ), - ), - ), - ], + if (_showIntro) + AnimatedPositioned( + duration: const Duration(milliseconds: 1450), + curve: Curves.easeInOutCubicEmphasized, + left: currentRect.left, + top: currentRect.top, + width: currentRect.width, + height: currentRect.height, + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.md), + image: DecorationImage( + image: AssetImage( + _signImageAssetForType( + context, + widget.data.signType, + ), + ), + fit: BoxFit.cover, + ), + boxShadow: [ + BoxShadow( + color: colors.shadow.withValues(alpha: 0.24), + blurRadius: 24, + offset: const Offset(0, 10), + ), + ], + ), + ), + ), + ), + ], + ); + }, + ), ), ); } @@ -217,18 +326,16 @@ class _ResultHeader extends StatelessWidget { } class _SignCard extends StatelessWidget { - const _SignCard({required this.signType}); + const _SignCard({super.key, required this.signType}); final String signType; @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final image = switch (signType) { - '上上签' => 'assets/images/qigua/shangshang.jpg', - '中上签' => 'assets/images/qigua/zhongshang.jpg', - _ => 'assets/images/qigua/zhongxia.jpg', - }; + final l10n = AppLocalizations.of(context)!; + final image = _signImageAssetForType(context, signType); + final localizedSignType = _localizedSignTypeLabel(l10n, signType); return Card( margin: EdgeInsets.zero, color: colors.surface, @@ -248,7 +355,7 @@ class _SignCard extends StatelessWidget { ), const SizedBox(height: AppSpacing.sm), Text( - signType, + localizedSignType, style: Theme.of(context).textTheme.titleLarge?.copyWith( color: colors.primary, fontWeight: FontWeight.w700, @@ -261,6 +368,35 @@ class _SignCard extends StatelessWidget { } } +String _localizedSignTypeLabel(AppLocalizations l10n, String signType) { + final normalized = signType.trim(); + if (normalized.contains('上上')) { + return l10n.signTypeShangShang; + } + if (normalized.contains('中上')) { + return l10n.signTypeZhongShang; + } + if (normalized.contains('下下')) { + return l10n.signTypeXiaXia; + } + return l10n.signTypeZhongXia; +} + +String _signImageAssetForType(BuildContext context, String signType) { + final l10n = AppLocalizations.of(context)!; + final normalized = _localizedSignTypeLabel(l10n, signType); + if (normalized == l10n.signTypeShangShang) { + return 'assets/images/qigua/shangshang.jpg'; + } + if (normalized == l10n.signTypeZhongShang) { + return 'assets/images/qigua/zhongshang.jpg'; + } + if (normalized == l10n.signTypeXiaXia) { + return 'assets/images/qigua/xiaxia.jpg'; + } + return 'assets/images/qigua/zhongxia.jpg'; +} + class _KeywordCard extends StatelessWidget { const _KeywordCard({required this.keywords}); @@ -299,6 +435,7 @@ class _AnalysisCard extends StatelessWidget { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return Card( margin: EdgeInsets.zero, color: colors.surface, @@ -323,9 +460,13 @@ class _AnalysisCard extends StatelessWidget { TextButton( onPressed: () { Clipboard.setData(ClipboardData(text: content)); - Toast.show(context, '$title已复制', type: ToastType.success); + Toast.show( + context, + l10n.toastContentCopiedWithTitle(title), + type: ToastType.success, + ); }, - child: const Text('复制'), + child: Text(l10n.resultCopy), ), ], ), @@ -351,6 +492,7 @@ class _InfoCard extends StatelessWidget { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return Card( margin: EdgeInsets.zero, color: colors.surface, @@ -360,32 +502,41 @@ class _InfoCard extends StatelessWidget { ), child: Padding( padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '起卦信息', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: colors.primary, - fontWeight: FontWeight.w700, + child: SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.resultDivinationInfo, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), ), - ), - const SizedBox(height: AppSpacing.md), - _kv( - context, - '起卦时间', - DateFormat.yMd( - Localizations.localeOf(context).toString(), - ).add_Hm().format(data.params.divinationTime), - ), - _kv( - context, - '起卦方式', - data.params.method == DivinationMethod.auto ? '自动起卦' : '手动起卦', - ), - _kv(context, '问题类型', _typeLabel(data.params.questionType)), - _kv(context, '占卜问题', data.params.question), - ], + const SizedBox(height: AppSpacing.md), + _kv( + context, + l10n.resultDivinationTime, + DateFormat.yMd( + Localizations.localeOf(context).toString(), + ).add_Hm().format(data.params.divinationTime), + ), + _kv( + context, + l10n.resultDivinationMethod, + data.params.method == DivinationMethod.auto + ? l10n.resultAutoMethod + : l10n.resultManualMethod, + ), + _kv( + context, + l10n.resultQuestionType, + _typeLabel(context, data.params.questionType), + ), + _kv(context, l10n.resultQuestion, data.params.question), + ], + ), ), ), ); @@ -419,17 +570,18 @@ class _InfoCard extends StatelessWidget { ); } - String _typeLabel(QuestionType type) { + String _typeLabel(BuildContext context, QuestionType type) { + final l10n = AppLocalizations.of(context)!; return switch (type) { - QuestionType.career => '事业', - QuestionType.love => '情感', - QuestionType.wealth => '财富', - QuestionType.fortune => '运势', - QuestionType.dream => '解梦', - QuestionType.health => '健康', - QuestionType.study => '学业', - QuestionType.search => '寻物', - QuestionType.other => '其他', + QuestionType.career => l10n.questionTypeCareer, + QuestionType.love => l10n.questionTypeLove, + QuestionType.wealth => l10n.questionTypeWealth, + QuestionType.fortune => l10n.questionTypeFortune, + QuestionType.dream => l10n.questionTypeDream, + QuestionType.health => l10n.questionTypeHealth, + QuestionType.study => l10n.questionTypeStudy, + QuestionType.search => l10n.questionTypeSearch, + QuestionType.other => l10n.questionTypeOther, }; } } @@ -442,6 +594,7 @@ class _HexagramDetailCard extends StatelessWidget { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; return Column( children: [ Card( @@ -457,7 +610,7 @@ class _HexagramDetailCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '干支信息', + l10n.ganZhiInfo, style: Theme.of(context).textTheme.titleMedium?.copyWith( color: colors.primary, fontWeight: FontWeight.w700, @@ -467,26 +620,58 @@ class _HexagramDetailCard extends StatelessWidget { Row( children: [ Expanded( - child: _miniKV(context, '月建', data.ganzhi.yueJian), + child: _miniKV( + context, + DivinationTerms.yueJian, + data.ganzhi.yueJian, + ), + ), + Expanded( + child: _miniKV( + context, + DivinationTerms.riChen, + data.ganzhi.riChen, + ), ), - Expanded(child: _miniKV(context, '日辰', data.ganzhi.riChen)), ], ), const SizedBox(height: AppSpacing.sm), Row( children: [ - Expanded(child: _miniKV(context, '月破', data.ganzhi.yuePo)), Expanded( - child: _miniKV(context, '日冲', data.ganzhi.riChong), + child: _miniKV( + context, + DivinationTerms.yuePo, + data.ganzhi.yuePo, + ), + ), + Expanded( + child: _miniKV( + context, + DivinationTerms.riChong, + data.ganzhi.riChong, + ), ), ], ), const SizedBox(height: AppSpacing.md), - Text('五行旺衰', style: Theme.of(context).textTheme.bodyMedium), + Text( + l10n.wuXingWangShuai, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), + ), const SizedBox(height: AppSpacing.sm), _WuXingTable(data: data), const SizedBox(height: AppSpacing.md), - Text('干支空亡', style: Theme.of(context).textTheme.bodyMedium), + Text( + l10n.ganZhiKongWang, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), + ), const SizedBox(height: AppSpacing.sm), _KongWangTable(data: data), ], @@ -626,12 +811,65 @@ class _KongWangTable extends StatelessWidget { @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final rows = [ - ('年', '${data.ganzhi.yearGanZhi}年', data.ganzhi.yearKongWang), - ('月', '${data.ganzhi.monthGanZhi}月', data.ganzhi.monthKongWang), - ('日', '${data.ganzhi.dayGanZhi}日', data.ganzhi.dayKongWang), - ('时', '${data.ganzhi.timeGanZhi}时', data.ganzhi.timeKongWang), + final l10n = AppLocalizations.of(context)!; + final header = [ + l10n.resultPillarColumn, + l10n.resultYearPillar, + l10n.resultMonthPillar, + l10n.resultDayPillar, + l10n.resultTimePillar, ]; + final rows = >[ + [ + l10n.resultGanZhiLabel, + data.ganzhi.yearGanZhi, + data.ganzhi.monthGanZhi, + data.ganzhi.dayGanZhi, + data.ganzhi.timeGanZhi, + ], + [ + l10n.resultKongWangLabel, + data.ganzhi.yearKongWang, + data.ganzhi.monthKongWang, + data.ganzhi.dayKongWang, + data.ganzhi.timeKongWang, + ], + ]; + + Widget buildCell( + String text, { + bool isHeader = false, + bool isLast = false, + bool isFirst = false, + }) { + return Expanded( + flex: isFirst ? 2 : 3, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: isHeader ? colors.surfaceContainerHigh : colors.surface, + border: Border( + right: isLast + ? BorderSide.none + : BorderSide(color: colors.outline), + ), + ), + child: Text( + text, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: isHeader || isFirst + ? FontWeight.w700 + : FontWeight.w500, + ), + ), + ), + ); + } + return Container( decoration: BoxDecoration( border: Border.all(color: colors.outline), @@ -639,20 +877,30 @@ class _KongWangTable extends StatelessWidget { ), child: Column( children: [ - for (final row in rows) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, + Row( + children: [ + for (int i = 0; i < header.length; i++) + buildCell( + header[i], + isHeader: true, + isFirst: i == 0, + isLast: i == header.length - 1, + ), + ], + ), + for (int r = 0; r < rows.length; r++) + Container( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: colors.outline)), ), child: Row( children: [ - SizedBox(width: 28, child: Text(row.$1)), - Expanded(child: Text(row.$2, textAlign: TextAlign.center)), - SizedBox( - width: 64, - child: Text(row.$3, textAlign: TextAlign.right), - ), + for (int c = 0; c < rows[r].length; c++) + buildCell( + rows[r][c], + isFirst: c == 0, + isLast: c == rows[r].length - 1, + ), ], ), ), diff --git a/apps/lib/features/divination/presentation/screens/divination_screen.dart b/apps/lib/features/divination/presentation/screens/divination_screen.dart index 5f92be2..dad6bd3 100644 --- a/apps/lib/features/divination/presentation/screens/divination_screen.dart +++ b/apps/lib/features/divination/presentation/screens/divination_screen.dart @@ -5,11 +5,13 @@ import '../../../../core/auth/session_store.dart'; import '../../../../data/network/api_client.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/widgets/app_modal_dialog.dart'; import '../../../../shared/widgets/divination/divination_shared_widgets.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import '../../data/apis/divination_api.dart'; import '../../data/models/divination_params.dart'; +import '../../data/models/divination_result.dart'; import '../../data/services/divination_run_service.dart'; import 'auto_divination_screen.dart'; import 'manual_divination_screen.dart'; @@ -19,11 +21,13 @@ class DivinationScreen extends StatefulWidget { super.key, required this.sessionStore, required this.userId, + required this.onCompleted, this.runServiceOverride, }); final SessionStore sessionStore; final String userId; + final Future Function(DivinationResultData result) onCompleted; final DivinationRunService? runServiceOverride; @override @@ -157,6 +161,7 @@ class _DivinationScreenState extends State { builder: (_) => ManualDivinationScreen( params: nextParams, runService: _runService, + onCompleted: widget.onCompleted, ), ), ); @@ -166,8 +171,11 @@ class _DivinationScreenState extends State { final nextParams = _params.copyWith(divinationTime: DateTime.now()); Navigator.of(context).push( MaterialPageRoute( - builder: (_) => - AutoDivinationScreen(params: nextParams, runService: _runService), + builder: (_) => AutoDivinationScreen( + params: nextParams, + runService: _runService, + onCompleted: widget.onCompleted, + ), ), ); } @@ -372,16 +380,17 @@ class _StartButton extends StatelessWidget { Future _showMethodTip(BuildContext context, AppLocalizations l10n) { return showDialog( context: context, - builder: (context) { - return AlertDialog( - title: Text(l10n.divinationMethodTipTitle), - content: Text( - '${l10n.divinationMethodTipAuto}\n\n${l10n.divinationMethodTipManual}\n\n${l10n.divinationMethodTipRecommend}', - ), + builder: (dialogContext) { + return AppModalDialog( + title: l10n.divinationMethodTipTitle, + message: + '${l10n.divinationMethodTipAuto}\n\n${l10n.divinationMethodTipManual}\n\n${l10n.divinationMethodTipRecommend}', + icon: Icons.lightbulb_outline_rounded, actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(l10n.divinationIAcknowledge), + AppModalDialogAction( + label: l10n.divinationIAcknowledge, + primary: true, + onPressed: () => Navigator.of(dialogContext).pop(), ), ], ); diff --git a/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart b/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart index 8e065c8..a452c21 100644 --- a/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart +++ b/apps/lib/features/divination/presentation/screens/manual_divination_screen.dart @@ -4,12 +4,17 @@ import 'package:intl/intl.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/widgets/app_modal_dialog.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'; +import '../../../../shared/widgets/toast/toast_type.dart'; +import '../../data/models/divination_backend_models.dart'; import '../../data/models/divination_params.dart'; +import '../../data/models/divination_result.dart'; import '../../data/services/divination_run_service.dart'; import 'divination_processing_screen.dart'; @@ -18,10 +23,12 @@ class ManualDivinationScreen extends StatefulWidget { super.key, required this.params, required this.runService, + required this.onCompleted, }); final DivinationParams params; final DivinationRunService runService; + final Future Function(DivinationResultData result) onCompleted; @override State createState() => _ManualDivinationScreenState(); @@ -155,14 +162,16 @@ class _ManualDivinationScreenState extends State final l10n = AppLocalizations.of(context)!; await showDialog( context: context, - builder: (context) { - return AlertDialog( - title: Text(l10n.manualYaoTipTitle), - content: Text(l10n.manualYaoTipContent), + builder: (dialogContext) { + return AppModalDialog( + title: l10n.manualYaoTipTitle, + message: l10n.manualYaoTipContent, + icon: Icons.info_outline_rounded, actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(l10n.divinationIAcknowledge), + AppModalDialogAction( + label: l10n.divinationIAcknowledge, + primary: true, + onPressed: () => Navigator.of(dialogContext).pop(), ), ], ); @@ -171,6 +180,55 @@ class _ManualDivinationScreenState extends State } Future _submitRun() async { + final l10n = AppLocalizations.of(context)!; + PointsBalanceData points; + try { + points = await widget.runService.getPointsBalance(); + } catch (_) { + if (!mounted) { + return; + } + Toast.show(context, l10n.errorRequestGeneric, type: ToastType.error); + return; + } + if (!points.canRun || points.availableBalance < points.runCost) { + if (!mounted) { + return; + } + Toast.show(context, l10n.toastCoinInsufficient, type: ToastType.warning); + return; + } + if (!mounted) { + return; + } + final shouldStart = await showDialog( + context: context, + builder: (dialogContext) { + return AppModalDialog( + title: l10n.divinationCostDialogTitle, + message: l10n.divinationCostDialogBody( + points.runCost, + points.availableBalance, + ), + icon: Icons.auto_awesome_rounded, + actions: [ + AppModalDialogAction( + label: l10n.cancel, + onPressed: () => Navigator.of(dialogContext).pop(false), + ), + AppModalDialogAction( + label: l10n.divinationCostDialogConfirm, + primary: true, + onPressed: () => Navigator.of(dialogContext).pop(true), + ), + ], + ); + }, + ); + if (shouldStart != true) { + return; + } + setState(() { _submitting = true; }); @@ -184,6 +242,7 @@ class _ManualDivinationScreenState extends State params: widget.params.copyWith(divinationTime: _selectedTime), yaoStates: _selectedYaos.cast(), runService: widget.runService, + onCompleted: widget.onCompleted, ), ), ); diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart index 332cf94..a9ecdb5 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import '../../../../core/auth/session_store.dart'; import '../../../divination/presentation/screens/divination_screen.dart'; +import '../../../divination/presentation/screens/divination_result_screen.dart'; +import '../../../divination/data/models/divination_params.dart'; +import '../../../divination/data/models/divination_result.dart'; import '../../../settings/data/models/profile_settings.dart'; import '../../../settings/presentation/screens/settings_screen.dart'; import '../../../../l10n/app_localizations.dart'; @@ -18,8 +21,12 @@ class HomeScreen extends StatefulWidget { required this.sessionStore, required this.currentLocale, required this.profileSettings, + required this.historyRecords, required this.coinBalance, required this.onLocaleChanged, + required this.onProfileSettingsChanged, + required this.onUploadAvatar, + required this.onDivinationCompleted, required this.onLogout, }); @@ -27,8 +34,14 @@ class HomeScreen extends StatefulWidget { final SessionStore sessionStore; final Locale currentLocale; final ProfileSettingsV1 profileSettings; + final List historyRecords; final int coinBalance; final Future Function(String languageTag) onLocaleChanged; + final Future Function(ProfileSettingsV1 settings) + onProfileSettingsChanged; + final Future Function(String filePath) onUploadAvatar; + final Future Function(DivinationResultData result) + onDivinationCompleted; final Future Function() onLogout; @override @@ -69,26 +82,7 @@ class _HomeScreenState extends State { final l10n = AppLocalizations.of(context)!; final colors = Theme.of(context).colorScheme; final palette = Theme.of(context).extension()!; - final historyItems = [ - _HistoryItemData( - question: l10n.historyQuestion1, - category: _HistoryCategory.career, - guaName: l10n.guaName1, - sign: _HistorySign.good, - ), - _HistoryItemData( - question: l10n.historyQuestion2, - category: _HistoryCategory.love, - guaName: l10n.guaName2, - sign: _HistorySign.normal, - ), - _HistoryItemData( - question: l10n.historyQuestion3, - category: _HistoryCategory.money, - guaName: l10n.guaName3, - sign: _HistorySign.best, - ), - ]; + final historyItems = widget.historyRecords; return Scaffold( backgroundColor: colors.surfaceContainerLow, @@ -212,7 +206,23 @@ class _HomeScreenState extends State { style: Theme.of(context).textTheme.titleMedium, ), TextButton( - onPressed: () => _showSnack(context, l10n.featurePending), + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _HistoryRecordsScreen( + records: historyItems, + onOpenResult: (item) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + DivinationResultScreen(data: item), + ), + ); + }, + ), + ), + ); + }, child: Text(l10n.more), ), ], @@ -245,7 +255,17 @@ class _HomeScreenState extends State { right: AppSpacing.md, bottom: AppSpacing.md, ), - child: _HistoryCard(item: item), + child: _HistoryCard( + item: item, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + DivinationResultScreen(data: item), + ), + ); + }, + ), ); }).toList(), ), @@ -270,6 +290,8 @@ class _HomeScreenState extends State { settings: widget.profileSettings, coinBalance: widget.coinBalance, onInterfaceLanguageChanged: widget.onLocaleChanged, + onSettingsChanged: widget.onProfileSettingsChanged, + onUploadAvatar: widget.onUploadAvatar, onLogout: widget.onLogout, ), ), @@ -283,6 +305,7 @@ class _HomeScreenState extends State { builder: (_) => DivinationScreen( sessionStore: widget.sessionStore, userId: widget.account, + onCompleted: widget.onDivinationCompleted, ), ), ); @@ -294,9 +317,10 @@ class _HomeScreenState extends State { } class _HistoryCard extends StatelessWidget { - const _HistoryCard({required this.item}); + const _HistoryCard({required this.item, required this.onTap}); - final _HistoryItemData item; + final DivinationResultData item; + final VoidCallback onTap; @override Widget build(BuildContext context) { @@ -304,80 +328,90 @@ class _HistoryCard extends StatelessWidget { final colors = Theme.of(context).colorScheme; final palette = Theme.of(context).extension()!; - final categoryLabel = switch (item.category) { - _HistoryCategory.career => l10n.categoryCareer, - _HistoryCategory.love => l10n.categoryLove, - _HistoryCategory.money => l10n.categoryMoney, + final categoryLabel = switch (item.params.questionType) { + QuestionType.career || QuestionType.study => l10n.categoryCareer, + QuestionType.love => l10n.categoryLove, + _ => l10n.categoryMoney, }; - final categoryStyle = switch (item.category) { - _HistoryCategory.career => ( + final categoryStyle = switch (item.params.questionType) { + QuestionType.career || QuestionType.study => ( palette.categoryCareerBg, palette.categoryCareerText, ), - _HistoryCategory.love => ( - palette.categoryLoveBg, - palette.categoryLoveText, - ), - _HistoryCategory.money => ( - palette.categoryMoneyBg, - palette.categoryMoneyText, - ), + QuestionType.love => (palette.categoryLoveBg, palette.categoryLoveText), + _ => (palette.categoryMoneyBg, palette.categoryMoneyText), }; - final signLabel = switch (item.sign) { - _HistorySign.best => l10n.signBest, - _HistorySign.good => l10n.signGood, - _HistorySign.normal => l10n.signNormal, - }; + final normalizedSignType = item.signType.trim(); + final isBestSign = normalizedSignType.contains('上上'); + final isGoodSign = !isBestSign && normalizedSignType.contains('中上'); + final isWorstSign = normalizedSignType.contains('下下'); - final signStyle = switch (item.sign) { - _HistorySign.best => (palette.historyGoldBg, palette.historyGoldText), - _HistorySign.good => (colors.surfaceContainerHighest, colors.primary), - _HistorySign.normal => (palette.historyGrayBg, palette.historyGrayText), - }; + final signLabel = isBestSign + ? l10n.signTypeShangShang + : isGoodSign + ? l10n.signTypeZhongShang + : isWorstSign + ? l10n.signTypeXiaXia + : l10n.signTypeZhongXia; - return Card( - margin: EdgeInsets.zero, - color: colors.surface, - elevation: 2, - shape: RoundedRectangleBorder( + final signStyle = isBestSign + ? (palette.historyGoldBg, palette.historyGoldText) + : isGoodSign + ? (colors.surfaceContainerHighest, colors.primary) + : isWorstSign + ? (colors.errorContainer, colors.onErrorContainer) + : (palette.historyGrayBg, palette.historyGrayText); + + return Material( + color: colors.surface.withValues(alpha: 0), + child: InkWell( borderRadius: BorderRadius.circular(AppRadius.md), - ), - child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.question, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.sm), - Wrap( - spacing: AppSpacing.sm, - runSpacing: AppSpacing.sm, + onTap: onTap, + child: Card( + margin: EdgeInsets.zero, + color: colors.surface, + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _Tag( - label: categoryLabel, - background: categoryStyle.$1, - foreground: categoryStyle.$2, + Text( + item.params.question, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium, ), - _Tag( - label: item.guaName, - background: palette.historyBlueBg, - foreground: palette.historyBlueText, - ), - _Tag( - label: signLabel, - background: signStyle.$1, - foreground: signStyle.$2, + const SizedBox(height: AppSpacing.sm), + Wrap( + spacing: AppSpacing.sm, + runSpacing: AppSpacing.sm, + children: [ + _Tag( + label: categoryLabel, + background: categoryStyle.$1, + foreground: categoryStyle.$2, + ), + _Tag( + label: item.guaName, + background: palette.historyBlueBg, + foreground: palette.historyBlueText, + ), + _Tag( + label: signLabel, + background: signStyle.$1, + foreground: signStyle.$2, + ), + ], ), ], ), - ], + ), ), ), ); @@ -416,6 +450,57 @@ class _Tag extends StatelessWidget { } } +class _HistoryRecordsScreen extends StatelessWidget { + const _HistoryRecordsScreen({ + required this.records, + required this.onOpenResult, + }); + + final List records; + final ValueChanged onOpenResult; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + return Scaffold( + backgroundColor: colors.surfaceContainerLow, + appBar: AppBar( + title: Text(l10n.historyTitle), + centerTitle: true, + backgroundColor: colors.surfaceContainerLow, + surfaceTintColor: colors.surfaceContainerLow, + ), + body: records.isEmpty + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.noRecords, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.sm), + Text(l10n.noRecordsSubtitle), + ], + ), + ) + : ListView.separated( + padding: const EdgeInsets.all(AppSpacing.md), + itemBuilder: (context, index) { + final item = records[index]; + return _HistoryCard( + item: item, + onTap: () => onOpenResult(item), + ); + }, + separatorBuilder: (_, _) => const SizedBox(height: AppSpacing.md), + itemCount: records.length, + ), + ); + } +} + class _WelcomeDialog extends StatefulWidget { const _WelcomeDialog({required this.onDone}); @@ -576,21 +661,3 @@ class _WelcomeDialogState extends State<_WelcomeDialog> { ); } } - -enum _HistoryCategory { career, love, money } - -enum _HistorySign { best, good, normal } - -class _HistoryItemData { - const _HistoryItemData({ - required this.question, - required this.category, - required this.guaName, - required this.sign, - }); - - final String question; - final _HistoryCategory category; - final String guaName; - final _HistorySign sign; -} diff --git a/apps/lib/features/settings/data/apis/profile_api.dart b/apps/lib/features/settings/data/apis/profile_api.dart new file mode 100644 index 0000000..5d4df6d --- /dev/null +++ b/apps/lib/features/settings/data/apis/profile_api.dart @@ -0,0 +1,90 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/network/api_problem.dart'; +import '../../../../data/network/api_client.dart'; +import '../models/profile_settings.dart'; + +class ProfileApi { + const ProfileApi({required ApiClient apiClient}) : _apiClient = apiClient; + + final ApiClient _apiClient; + + Future getProfile() async { + final json = await _apiClient.getJson('/api/v1/users/me/profile'); + return _toSettings(json); + } + + Future updateProfile(ProfileSettingsV1 next) async { + final payload = { + 'display_name': next.displayName, + 'bio': next.bio, + if (next.avatarPath != null && next.avatarPath!.isNotEmpty) + 'avatar_path': next.avatarPath, + }; + final json = await _apiClient.rawDio.patch>( + '/api/v1/users/me/profile', + data: payload, + ); + final data = json.data; + if (data is! Map) { + throw ApiProblem( + status: 502, + title: 'Invalid profile payload', + detail: 'Expected profile response object', + ); + } + return _toSettings(data); + } + + Future uploadAvatar(String filePath) async { + final formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile(filePath), + }); + final response = await _apiClient.rawDio.post>( + '/api/v1/users/me/avatar', + data: formData, + ); + final data = response.data; + if (data is! Map) { + throw ApiProblem( + status: 502, + title: 'Invalid profile payload', + detail: 'Expected profile response object', + ); + } + return _toSettings(data); + } + + ProfileSettingsV1 _toSettings(Map json) { + final settingsRaw = json['settings']; + final preferencesRaw = settingsRaw is Map + ? settingsRaw['preferences'] + : null; + final preferences = preferencesRaw is Map + ? PreferenceSettings( + interfaceLanguage: + (preferencesRaw['interface_language'] as String?) ?? 'zh-CN', + aiLanguage: (preferencesRaw['ai_language'] as String?) ?? 'zh-CN', + timezone: + (preferencesRaw['timezone'] as String?) ?? 'Asia/Shanghai', + country: (preferencesRaw['country'] as String?) ?? 'CN', + ) + : const PreferenceSettings(); + + return ProfileSettingsV1( + displayName: (json['display_name'] as String?) ?? '', + bio: (json['bio'] as String?) ?? '', + avatarPath: json['avatar_path'] as String?, + avatarUrl: json['avatar_url'] as String?, + preferences: preferences, + privacy: settingsRaw is Map + ? (settingsRaw['privacy'] as Map? ?? + const {}) + : const {}, + notification: settingsRaw is Map + ? (settingsRaw['notification'] as Map? ?? + const {}) + : const {}, + ); + } +} diff --git a/apps/lib/features/settings/data/models/profile_settings.dart b/apps/lib/features/settings/data/models/profile_settings.dart index 2e6555f..4ef5038 100644 --- a/apps/lib/features/settings/data/models/profile_settings.dart +++ b/apps/lib/features/settings/data/models/profile_settings.dart @@ -40,24 +40,40 @@ class PreferenceSettings { class ProfileSettingsV1 { const ProfileSettingsV1({ this.version = 1, + this.displayName = '', + this.bio = '', + this.avatarPath, + this.avatarUrl, this.preferences = const PreferenceSettings(), this.privacy = const {}, this.notification = const {}, }); final int version; + final String displayName; + final String bio; + final String? avatarPath; + final String? avatarUrl; final PreferenceSettings preferences; final Map privacy; final Map notification; ProfileSettingsV1 copyWith({ int? version, + String? displayName, + String? bio, + String? avatarPath, + String? avatarUrl, PreferenceSettings? preferences, Map? privacy, Map? notification, }) { return ProfileSettingsV1( version: version ?? this.version, + displayName: displayName ?? this.displayName, + bio: bio ?? this.bio, + avatarPath: avatarPath ?? this.avatarPath, + avatarUrl: avatarUrl ?? this.avatarUrl, preferences: preferences ?? this.preferences, privacy: privacy ?? this.privacy, notification: notification ?? this.notification, diff --git a/apps/lib/features/settings/presentation/screens/profile_edit_screen.dart b/apps/lib/features/settings/presentation/screens/profile_edit_screen.dart new file mode 100644 index 0000000..6d1f633 --- /dev/null +++ b/apps/lib/features/settings/presentation/screens/profile_edit_screen.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../../../core/logging/logger.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/widgets/toast/toast.dart'; +import '../../../../shared/widgets/toast/toast_type.dart'; +import '../../data/models/profile_settings.dart'; + +class ProfileEditScreen extends StatefulWidget { + const ProfileEditScreen({ + super.key, + required this.account, + required this.settings, + required this.onUploadAvatar, + }); + + final String account; + final ProfileSettingsV1 settings; + final Future Function(String filePath) onUploadAvatar; + + @override + State createState() => _ProfileEditScreenState(); +} + +class _ProfileEditScreenState extends State { + final Logger _logger = getLogger('features.settings.profile_edit_screen'); + final ImagePicker _imagePicker = ImagePicker(); + late final TextEditingController _nameController; + late final TextEditingController _bioController; + bool _uploadingAvatar = false; + String? _avatarPath; + String? _avatarPreviewUrl; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController( + text: widget.settings.displayName.isEmpty + ? widget.account + : widget.settings.displayName, + ); + _bioController = TextEditingController(text: widget.settings.bio); + _avatarPath = widget.settings.avatarPath; + _avatarPreviewUrl = widget.settings.avatarUrl; + } + + @override + void dispose() { + _nameController.dispose(); + _bioController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + backgroundColor: colors.surfaceContainerLow, + appBar: AppBar( + title: Text(l10n.settingsEditProfileTitle), + centerTitle: true, + backgroundColor: colors.surfaceContainerLow, + surfaceTintColor: colors.surfaceContainerLow, + ), + body: ListView( + padding: const EdgeInsets.all(AppSpacing.lg), + children: [ + Container( + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: colors.outlineVariant), + ), + child: Column( + children: [ + Text( + l10n.settingsAvatar, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.lg), + Stack( + alignment: Alignment.bottomRight, + children: [ + Container( + width: 112, + height: 112, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colors.surfaceContainerHighest, + border: Border.all( + color: colors.primary.withValues(alpha: 0.3), + width: 2, + ), + ), + clipBehavior: Clip.antiAlias, + child: + (_avatarPreviewUrl != null && + _avatarPreviewUrl!.isNotEmpty) + ? Image.network( + _avatarPreviewUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Icon( + Icons.person, + size: 44, + color: colors.primary, + ); + }, + ) + : Icon(Icons.person, size: 44, color: colors.primary), + ), + FilledButton( + onPressed: _uploadingAvatar ? null : _pickAndUploadAvatar, + style: FilledButton.styleFrom( + minimumSize: const Size(44, 44), + shape: const CircleBorder(), + padding: EdgeInsets.zero, + ), + child: _uploadingAvatar + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.photo_camera_outlined, size: 20), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _uploadingAvatar ? null : _pickAndUploadAvatar, + icon: const Icon(Icons.photo_library_outlined), + label: Text( + _uploadingAvatar + ? l10n.settingsAvatarUploading + : l10n.settingsAvatarChooseFromAlbum, + ), + ), + ), + ], + ), + ), + const SizedBox(height: AppSpacing.xl), + Text( + l10n.settingsDisplayName, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.sm), + TextField( + controller: _nameController, + maxLength: 20, + decoration: InputDecoration( + hintText: l10n.settingsDisplayNameHint, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + Text( + l10n.settingsBio, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.sm), + TextField( + controller: _bioController, + minLines: 3, + maxLines: 5, + maxLength: 80, + decoration: InputDecoration( + hintText: l10n.settingsBioHint, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + ), + ), + const SizedBox(height: AppSpacing.xl), + SizedBox( + width: double.infinity, + child: FilledButton(onPressed: _save, child: Text(l10n.confirm)), + ), + ], + ), + ); + } + + void _save() { + final l10n = AppLocalizations.of(context)!; + final name = _nameController.text.trim(); + if (name.isEmpty) { + Toast.show( + context, + l10n.settingsDisplayNameRequired, + type: ToastType.warning, + ); + return; + } + Navigator.of(context).pop( + widget.settings.copyWith( + displayName: name, + bio: _bioController.text.trim(), + avatarPath: _avatarPath, + avatarUrl: _avatarPreviewUrl, + ), + ); + } + + Future _pickAndUploadAvatar() async { + XFile? picked; + try { + picked = await _imagePicker.pickImage( + source: ImageSource.gallery, + maxWidth: 1024, + imageQuality: 85, + requestFullMetadata: false, + ); + } catch (error, stackTrace) { + _logger.error( + message: 'Avatar picker failed to open photo library', + error: error, + stackTrace: stackTrace, + ); + if (!mounted) { + return; + } + Toast.show( + context, + AppLocalizations.of(context)!.settingsAvatarPickPermissionHint, + type: ToastType.error, + ); + return; + } + + if (picked == null || !mounted) { + return; + } + setState(() { + _uploadingAvatar = true; + }); + try { + final updated = await widget.onUploadAvatar(picked.path); + if (!mounted) { + return; + } + setState(() { + _avatarPath = updated.avatarPath; + _avatarPreviewUrl = updated.avatarUrl; + }); + } catch (error, stackTrace) { + _logger.error( + message: 'Avatar upload failed from profile editor', + error: error, + stackTrace: stackTrace, + ); + if (!mounted) { + return; + } + Toast.show( + context, + AppLocalizations.of(context)!.errorRequestGeneric, + type: ToastType.error, + ); + } finally { + if (mounted) { + setState(() { + _uploadingAvatar = false; + }); + } + } + } +} diff --git a/apps/lib/features/settings/presentation/screens/settings_screen.dart b/apps/lib/features/settings/presentation/screens/settings_screen.dart index 556b2a8..7ed064c 100644 --- a/apps/lib/features/settings/presentation/screens/settings_screen.dart +++ b/apps/lib/features/settings/presentation/screens/settings_screen.dart @@ -1,12 +1,17 @@ import 'package:flutter/material.dart'; import '../../../../l10n/app_localizations.dart'; +import '../../../../core/logging/logger.dart'; import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/widgets/app_modal_dialog.dart'; +import '../../../../shared/widgets/toast/toast.dart'; +import '../../../../shared/widgets/toast/toast_type.dart'; import '../../data/models/profile_settings.dart'; import '../widgets/settings_section_widgets.dart'; import 'coin_center_screen.dart'; import 'general_settings_screen.dart'; import 'legal_center_screen.dart'; +import 'profile_edit_screen.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({ @@ -15,6 +20,8 @@ class SettingsScreen extends StatefulWidget { required this.settings, required this.coinBalance, required this.onInterfaceLanguageChanged, + required this.onSettingsChanged, + required this.onUploadAvatar, required this.onLogout, }); @@ -22,6 +29,8 @@ class SettingsScreen extends StatefulWidget { final ProfileSettingsV1 settings; final int coinBalance; final Future Function(String languageTag) onInterfaceLanguageChanged; + final Future Function(ProfileSettingsV1 settings) onSettingsChanged; + final Future Function(String filePath) onUploadAvatar; final Future Function() onLogout; @override @@ -29,6 +38,7 @@ class SettingsScreen extends StatefulWidget { } class _SettingsScreenState extends State { + final Logger _logger = getLogger('features.settings.settings_screen'); late ProfileSettingsV1 _settings; bool _isLoggingOut = false; @@ -38,6 +48,14 @@ class _SettingsScreenState extends State { _settings = widget.settings; } + @override + void didUpdateWidget(covariant SettingsScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.settings != widget.settings) { + _settings = widget.settings; + } + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -59,7 +77,15 @@ class _SettingsScreenState extends State { AppSpacing.xl, ), children: [ - ProfileHeaderCard(account: widget.account), + ProfileHeaderCard( + account: widget.account, + displayName: _settings.displayName.isEmpty + ? widget.account + : _settings.displayName, + bio: _settings.bio, + avatarUrl: _settings.avatarUrl, + onEditTap: _openProfileEdit, + ), const SizedBox(height: AppSpacing.lg), WalletHeroCard( balance: widget.coinBalance, @@ -67,7 +93,6 @@ class _SettingsScreenState extends State { onTap: _openCoinCenter, ), const SizedBox(height: AppSpacing.xl), - SectionLabel(text: l10n.settingsSectionQuickAccess), SettingsGroupCard( children: [ SettingsMenuTile( @@ -131,6 +156,44 @@ class _SettingsScreenState extends State { }); } + Future _openProfileEdit() async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ProfileEditScreen( + account: widget.account, + settings: _settings, + onUploadAvatar: widget.onUploadAvatar, + ), + ), + ); + if (result == null || !mounted) { + return; + } + try { + await widget.onSettingsChanged(result); + if (!mounted) { + return; + } + setState(() { + _settings = result; + }); + } catch (error, stackTrace) { + _logger.error( + message: 'Failed to save profile settings', + error: error, + stackTrace: stackTrace, + ); + if (!mounted) { + return; + } + Toast.show( + context, + AppLocalizations.of(context)!.errorRequestGeneric, + type: ToastType.error, + ); + } + } + Future _openLegalCenter() async { await Navigator.of(context).push( MaterialPageRoute(builder: (_) => const LegalCenterScreen()), @@ -142,21 +205,20 @@ class _SettingsScreenState extends State { final confirmed = await showDialog( context: context, builder: (dialogContext) { - return AlertDialog( - title: Text(l10n.settingsLogoutDialogTitle), - content: Text(l10n.settingsLogoutDialogBody), + return AppModalDialog( + title: l10n.settingsLogoutDialogTitle, + message: l10n.settingsLogoutDialogBody, + icon: Icons.logout_rounded, actions: [ - TextButton( + AppModalDialogAction( + label: l10n.settingsCancel, onPressed: () => Navigator.of(dialogContext).pop(false), - child: Text(l10n.settingsCancel), ), - FilledButton( + AppModalDialogAction( + label: l10n.logout, + primary: true, + destructive: true, onPressed: () => Navigator.of(dialogContext).pop(true), - style: FilledButton.styleFrom( - backgroundColor: Theme.of(dialogContext).colorScheme.error, - foregroundColor: Theme.of(dialogContext).colorScheme.onError, - ), - child: Text(l10n.logout), ), ], ); @@ -171,6 +233,10 @@ class _SettingsScreenState extends State { }); try { await widget.onLogout(); + if (!mounted) { + return; + } + Navigator.of(context).popUntil((route) => route.isFirst); } finally { if (mounted) { setState(() { diff --git a/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart b/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart index c1c8e19..9ee8c17 100644 --- a/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart +++ b/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart @@ -120,9 +120,20 @@ class SettingsMenuTile extends StatelessWidget { } class ProfileHeaderCard extends StatelessWidget { - const ProfileHeaderCard({super.key, required this.account}); + const ProfileHeaderCard({ + super.key, + required this.account, + required this.displayName, + required this.bio, + required this.avatarUrl, + required this.onEditTap, + }); final String account; + final String displayName; + final String bio; + final String? avatarUrl; + final VoidCallback onEditTap; @override Widget build(BuildContext context) { @@ -137,22 +148,83 @@ class ProfileHeaderCard extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(AppSpacing.lg), child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - radius: 28, - backgroundColor: colors.surfaceContainerHighest, - child: Icon(Icons.person_rounded, color: colors.primary), + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + alignment: Alignment.center, + child: _AvatarContent(avatarUrl: avatarUrl), ), const SizedBox(width: AppSpacing.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(account, style: Theme.of(context).textTheme.titleMedium), + Text( + displayName, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.xs), + Text( + account, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + if (bio.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.sm), + Text( + bio, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], ], ), ), - Icon(Icons.edit_outlined, color: colors.outline, size: 20), + const SizedBox(width: AppSpacing.sm), + Material( + color: colors.surface, + elevation: 2, + shadowColor: colors.shadow.withValues(alpha: 0.35), + borderRadius: BorderRadius.circular(AppRadius.full), + child: InkWell( + onTap: onEditTap, + borderRadius: BorderRadius.circular(AppRadius.full), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all( + color: colors.primary.withValues(alpha: 0.24), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.edit_rounded, color: colors.primary, size: 18), + const SizedBox(width: AppSpacing.xs), + Text( + AppLocalizations.of(context)!.settingsEditProfileAction, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ), ], ), ), @@ -160,6 +232,32 @@ class ProfileHeaderCard extends StatelessWidget { } } +class _AvatarContent extends StatelessWidget { + const _AvatarContent({required this.avatarUrl}); + + final String? avatarUrl; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final url = avatarUrl?.trim() ?? ''; + if (url.isNotEmpty) { + return ClipOval( + child: Image.network( + url, + width: 56, + height: 56, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Icon(Icons.person_rounded, color: colors.primary, size: 30); + }, + ), + ); + } + return Icon(Icons.person_rounded, color: colors.primary, size: 30); + } +} + class WalletHeroCard extends StatelessWidget { const WalletHeroCard({ super.key, diff --git a/apps/lib/l10n/app_en.arb b/apps/lib/l10n/app_en.arb index a7afb04..ba93a75 100644 --- a/apps/lib/l10n/app_en.arb +++ b/apps/lib/l10n/app_en.arb @@ -74,9 +74,10 @@ "categoryCareer": "Career/Study", "categoryLove": "Love/Marriage", "categoryMoney": "Wealth/Investment", - "signBest": "Excellent", - "signGood": "Good", - "signNormal": "Moderate", + "signBest": "Supremely Auspicious", + "signGood": "Auspicious", + "signNormal": "Cautionary", + "signBad": "Inauspicious", "language": "Language", "settingsTitle": "Settings", "settingsSectionGeneral": "General", @@ -142,6 +143,19 @@ "settingsCancel": "Cancel", "settingsLogoutConfirmHint": "Tap again to confirm logout", "settingsLogoutConfirmAction": "Tap again to logout", + "settingsEditProfileAction": "Edit", + "settingsEditProfileTitle": "Edit Profile", + "settingsAvatar": "Avatar", + "settingsDisplayName": "Display Name", + "settingsDisplayNameHint": "Enter display name", + "settingsDisplayNameRequired": "Display name is required", + "settingsBio": "Bio", + "settingsBioHint": "Write a short introduction", + "settingsAvatarPickerHint": "Supports PNG / JPG / WEBP. A clear square photo works best.", + "settingsAvatarChooseFromAlbum": "Choose from Photos", + "settingsAvatarUploading": "Uploading...", + "settingsAvatarUploadSuccess": "Avatar uploaded", + "settingsAvatarPickPermissionHint": "Cannot open photo library. Please allow Photos access in system settings.", "settingsLanguageSection": "Interface Language", "settingsCoinBalanceLabel": "Current Credits", "settingsCoinBalanceValue": "{balance} credits", @@ -227,6 +241,19 @@ "questionTypeOther": "Other", "toastPleaseInputQuestion": "Please enter your question", "toastCoinInsufficient": "Insufficient coins", + "divinationCostDialogTitle": "Confirm divination", + "divinationCostDialogBody": "This run costs {cost} credits. Available balance: {balance} credits. Continue?", + "@divinationCostDialogBody": { + "placeholders": { + "cost": { + "type": "int" + }, + "balance": { + "type": "int" + } + } + }, + "divinationCostDialogConfirm": "Start", "toastContentCopied": "Content copied", "toastContentCopiedWithTitle": "{title} copied", "@toastContentCopiedWithTitle": { @@ -251,14 +278,41 @@ "resultQuestion": "Question", "resultAutoMethod": "Auto", "resultManualMethod": "Manual", + "signTypeShangShang": "Supremely Auspicious", + "signTypeZhongShang": "Auspicious", + "signTypeZhongXia": "Cautionary", + "signTypeXiaXia": "Inauspicious", "resultCopy": "Copy", "resultWarning": "All interpretations are AI-generated for entertainment only. Do not use them as professional advice.", "transitionPreparing": "Deriving...", "transitionDeriving": "Analyzing...", "transitionDone": "Complete\nTap to view", + "processingCardQianTitle": "Qian • The Creative", + "processingCardQianQuote": "The movement of Heaven is full of power; thus the noble one makes himself strong and tireless.", + "processingCardDuiTitle": "Dui • The Joyous", + "processingCardDuiQuote": "Joy grounded in integrity brings openness, harmony, and right expression.", + "processingCardLiTitle": "Li • The Clinging Fire", + "processingCardLiQuote": "With clear brilliance, the great one illumines all directions.", + "processingCardZhenTitle": "Zhen • The Arousing Thunder", + "processingCardZhenQuote": "Shock awakens the heart; composure turns fear into growth.", + "processingCardXunTitle": "Xun • The Gentle Wind", + "processingCardXunQuote": "Gentle penetration furthers progress and helps one meet the right people.", + "processingCardKanTitle": "Kan • The Abysmal Water", + "processingCardKanQuote": "In danger, sincerity and disciplined action carry one through.", + "processingCardGenTitle": "Gen • Keeping Still Mountain", + "processingCardGenQuote": "Stillness at the proper time keeps one centered and steady in place.", + "processingCardKunTitle": "Kun • The Receptive Earth", + "processingCardKunQuote": "The Earth's condition is devoted receptivity; the noble one carries all with broad virtue.", "ganZhiInfo": "GanZhi Info", "wuXingWangShuai": "WuXing Strength", - "ganZhiKongWang": "KongWang", + "ganZhiKongWang": "KongWang Info", + "resultPillarColumn": "Pillar", + "resultYearPillar": "Year", + "resultMonthPillar": "Month", + "resultDayPillar": "Day", + "resultTimePillar": "Hour", + "resultGanZhiLabel": "GanZhi", + "resultKongWangLabel": "KongWang", "manualScreenTitle": "Manual Casting", "manualSelectTime": "Select time", "manualSpecifyYaoCombo": "Select coin combination", diff --git a/apps/lib/l10n/app_localizations.dart b/apps/lib/l10n/app_localizations.dart index c9b89c2..7260e52 100644 --- a/apps/lib/l10n/app_localizations.dart +++ b/apps/lib/l10n/app_localizations.dart @@ -476,6 +476,12 @@ abstract class AppLocalizations { /// **'中下签'** String get signNormal; + /// No description provided for @signBad. + /// + /// In zh, this message translates to: + /// **'下下签'** + String get signBad; + /// No description provided for @language. /// /// In zh, this message translates to: @@ -740,6 +746,84 @@ abstract class AppLocalizations { /// **'再次点击确认退出'** String get settingsLogoutConfirmAction; + /// No description provided for @settingsEditProfileAction. + /// + /// In zh, this message translates to: + /// **'编辑'** + String get settingsEditProfileAction; + + /// No description provided for @settingsEditProfileTitle. + /// + /// In zh, this message translates to: + /// **'编辑个人信息'** + String get settingsEditProfileTitle; + + /// No description provided for @settingsAvatar. + /// + /// In zh, this message translates to: + /// **'头像'** + String get settingsAvatar; + + /// No description provided for @settingsDisplayName. + /// + /// In zh, this message translates to: + /// **'昵称'** + String get settingsDisplayName; + + /// No description provided for @settingsDisplayNameHint. + /// + /// In zh, this message translates to: + /// **'请输入昵称'** + String get settingsDisplayNameHint; + + /// No description provided for @settingsDisplayNameRequired. + /// + /// In zh, this message translates to: + /// **'请输入昵称后再保存'** + String get settingsDisplayNameRequired; + + /// No description provided for @settingsBio. + /// + /// In zh, this message translates to: + /// **'个人简介'** + String get settingsBio; + + /// No description provided for @settingsBioHint. + /// + /// In zh, this message translates to: + /// **'一句话介绍你自己'** + String get settingsBioHint; + + /// No description provided for @settingsAvatarPickerHint. + /// + /// In zh, this message translates to: + /// **'支持 PNG / JPG / WEBP,建议上传清晰正方形头像'** + String get settingsAvatarPickerHint; + + /// No description provided for @settingsAvatarChooseFromAlbum. + /// + /// In zh, this message translates to: + /// **'从相册选择头像'** + String get settingsAvatarChooseFromAlbum; + + /// No description provided for @settingsAvatarUploading. + /// + /// In zh, this message translates to: + /// **'上传中...'** + String get settingsAvatarUploading; + + /// No description provided for @settingsAvatarUploadSuccess. + /// + /// In zh, this message translates to: + /// **'头像上传成功'** + String get settingsAvatarUploadSuccess; + + /// No description provided for @settingsAvatarPickPermissionHint. + /// + /// In zh, this message translates to: + /// **'无法打开相册,请在系统设置中允许照片访问权限'** + String get settingsAvatarPickPermissionHint; + /// No description provided for @settingsLanguageSection. /// /// In zh, this message translates to: @@ -1124,6 +1208,24 @@ abstract class AppLocalizations { /// **'铜钱不足,无法解卦'** String get toastCoinInsufficient; + /// No description provided for @divinationCostDialogTitle. + /// + /// In zh, this message translates to: + /// **'确认开始解卦'** + String get divinationCostDialogTitle; + + /// No description provided for @divinationCostDialogBody. + /// + /// In zh, this message translates to: + /// **'本次解卦将消耗 {cost} 点数,当前可用 {balance} 点数。是否继续?'** + String divinationCostDialogBody(int cost, int balance); + + /// No description provided for @divinationCostDialogConfirm. + /// + /// In zh, this message translates to: + /// **'确认解卦'** + String get divinationCostDialogConfirm; + /// No description provided for @toastContentCopied. /// /// In zh, this message translates to: @@ -1226,6 +1328,30 @@ abstract class AppLocalizations { /// **'手动起卦'** String get resultManualMethod; + /// No description provided for @signTypeShangShang. + /// + /// In zh, this message translates to: + /// **'上上签'** + String get signTypeShangShang; + + /// No description provided for @signTypeZhongShang. + /// + /// In zh, this message translates to: + /// **'中上签'** + String get signTypeZhongShang; + + /// No description provided for @signTypeZhongXia. + /// + /// In zh, this message translates to: + /// **'中下签'** + String get signTypeZhongXia; + + /// No description provided for @signTypeXiaXia. + /// + /// In zh, this message translates to: + /// **'下下签'** + String get signTypeXiaXia; + /// No description provided for @resultCopy. /// /// In zh, this message translates to: @@ -1256,6 +1382,102 @@ abstract class AppLocalizations { /// **'解卦完成\n点击查看'** String get transitionDone; + /// No description provided for @processingCardQianTitle. + /// + /// In zh, this message translates to: + /// **'Qian • The Creative'** + String get processingCardQianTitle; + + /// No description provided for @processingCardQianQuote. + /// + /// In zh, this message translates to: + /// **'The movement of Heaven is full of power; thus the noble one makes himself strong and tireless.'** + String get processingCardQianQuote; + + /// No description provided for @processingCardDuiTitle. + /// + /// In zh, this message translates to: + /// **'Dui • The Joyous'** + String get processingCardDuiTitle; + + /// No description provided for @processingCardDuiQuote. + /// + /// In zh, this message translates to: + /// **'Joy grounded in integrity brings openness, harmony, and right expression.'** + String get processingCardDuiQuote; + + /// No description provided for @processingCardLiTitle. + /// + /// In zh, this message translates to: + /// **'Li • The Clinging Fire'** + String get processingCardLiTitle; + + /// No description provided for @processingCardLiQuote. + /// + /// In zh, this message translates to: + /// **'With clear brilliance, the great one illumines all directions.'** + String get processingCardLiQuote; + + /// No description provided for @processingCardZhenTitle. + /// + /// In zh, this message translates to: + /// **'Zhen • The Arousing Thunder'** + String get processingCardZhenTitle; + + /// No description provided for @processingCardZhenQuote. + /// + /// In zh, this message translates to: + /// **'Shock awakens the heart; composure turns fear into growth.'** + String get processingCardZhenQuote; + + /// No description provided for @processingCardXunTitle. + /// + /// In zh, this message translates to: + /// **'Xun • The Gentle Wind'** + String get processingCardXunTitle; + + /// No description provided for @processingCardXunQuote. + /// + /// In zh, this message translates to: + /// **'Gentle penetration furthers progress and helps one meet the right people.'** + String get processingCardXunQuote; + + /// No description provided for @processingCardKanTitle. + /// + /// In zh, this message translates to: + /// **'Kan • The Abysmal Water'** + String get processingCardKanTitle; + + /// No description provided for @processingCardKanQuote. + /// + /// In zh, this message translates to: + /// **'In danger, sincerity and disciplined action carry one through.'** + String get processingCardKanQuote; + + /// No description provided for @processingCardGenTitle. + /// + /// In zh, this message translates to: + /// **'Gen • Keeping Still Mountain'** + String get processingCardGenTitle; + + /// No description provided for @processingCardGenQuote. + /// + /// In zh, this message translates to: + /// **'Stillness at the proper time keeps one centered and steady in place.'** + String get processingCardGenQuote; + + /// No description provided for @processingCardKunTitle. + /// + /// In zh, this message translates to: + /// **'Kun • The Receptive Earth'** + String get processingCardKunTitle; + + /// No description provided for @processingCardKunQuote. + /// + /// In zh, this message translates to: + /// **'The Earth\'s condition is devoted receptivity; the noble one carries all with broad virtue.'** + String get processingCardKunQuote; + /// No description provided for @ganZhiInfo. /// /// In zh, this message translates to: @@ -1271,9 +1493,51 @@ abstract class AppLocalizations { /// No description provided for @ganZhiKongWang. /// /// In zh, this message translates to: - /// **'干支空亡'** + /// **'空亡信息'** String get ganZhiKongWang; + /// No description provided for @resultPillarColumn. + /// + /// In zh, this message translates to: + /// **'四柱'** + String get resultPillarColumn; + + /// No description provided for @resultYearPillar. + /// + /// In zh, this message translates to: + /// **'年柱'** + String get resultYearPillar; + + /// No description provided for @resultMonthPillar. + /// + /// In zh, this message translates to: + /// **'月柱'** + String get resultMonthPillar; + + /// No description provided for @resultDayPillar. + /// + /// In zh, this message translates to: + /// **'日柱'** + String get resultDayPillar; + + /// No description provided for @resultTimePillar. + /// + /// In zh, this message translates to: + /// **'时柱'** + String get resultTimePillar; + + /// No description provided for @resultGanZhiLabel. + /// + /// In zh, this message translates to: + /// **'干支'** + String get resultGanZhiLabel; + + /// No description provided for @resultKongWangLabel. + /// + /// In zh, this message translates to: + /// **'空亡'** + String get resultKongWangLabel; + /// No description provided for @manualScreenTitle. /// /// In zh, this message translates to: diff --git a/apps/lib/l10n/app_localizations_en.dart b/apps/lib/l10n/app_localizations_en.dart index 592cd27..47b9204 100644 --- a/apps/lib/l10n/app_localizations_en.dart +++ b/apps/lib/l10n/app_localizations_en.dart @@ -199,13 +199,16 @@ class AppLocalizationsEn extends AppLocalizations { String get categoryMoney => 'Wealth/Investment'; @override - String get signBest => 'Excellent'; + String get signBest => 'Supremely Auspicious'; @override - String get signGood => 'Good'; + String get signGood => 'Auspicious'; @override - String get signNormal => 'Moderate'; + String get signNormal => 'Cautionary'; + + @override + String get signBad => 'Inauspicious'; @override String get language => 'Language'; @@ -355,6 +358,47 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsLogoutConfirmAction => 'Tap again to logout'; + @override + String get settingsEditProfileAction => 'Edit'; + + @override + String get settingsEditProfileTitle => 'Edit Profile'; + + @override + String get settingsAvatar => 'Avatar'; + + @override + String get settingsDisplayName => 'Display Name'; + + @override + String get settingsDisplayNameHint => 'Enter display name'; + + @override + String get settingsDisplayNameRequired => 'Display name is required'; + + @override + String get settingsBio => 'Bio'; + + @override + String get settingsBioHint => 'Write a short introduction'; + + @override + String get settingsAvatarPickerHint => + 'Supports PNG / JPG / WEBP. A clear square photo works best.'; + + @override + String get settingsAvatarChooseFromAlbum => 'Choose from Photos'; + + @override + String get settingsAvatarUploading => 'Uploading...'; + + @override + String get settingsAvatarUploadSuccess => 'Avatar uploaded'; + + @override + String get settingsAvatarPickPermissionHint => + 'Cannot open photo library. Please allow Photos access in system settings.'; + @override String get settingsLanguageSection => 'Interface Language'; @@ -567,6 +611,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get toastCoinInsufficient => 'Insufficient coins'; + @override + String get divinationCostDialogTitle => 'Confirm divination'; + + @override + String divinationCostDialogBody(int cost, int balance) { + return 'This run costs $cost credits. Available balance: $balance credits. Continue?'; + } + + @override + String get divinationCostDialogConfirm => 'Start'; + @override String get toastContentCopied => 'Content copied'; @@ -620,6 +675,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get resultManualMethod => 'Manual'; + @override + String get signTypeShangShang => 'Supremely Auspicious'; + + @override + String get signTypeZhongShang => 'Auspicious'; + + @override + String get signTypeZhongXia => 'Cautionary'; + + @override + String get signTypeXiaXia => 'Inauspicious'; + @override String get resultCopy => 'Copy'; @@ -636,6 +703,62 @@ class AppLocalizationsEn extends AppLocalizations { @override String get transitionDone => 'Complete\nTap to view'; + @override + String get processingCardQianTitle => 'Qian • The Creative'; + + @override + String get processingCardQianQuote => + 'The movement of Heaven is full of power; thus the noble one makes himself strong and tireless.'; + + @override + String get processingCardDuiTitle => 'Dui • The Joyous'; + + @override + String get processingCardDuiQuote => + 'Joy grounded in integrity brings openness, harmony, and right expression.'; + + @override + String get processingCardLiTitle => 'Li • The Clinging Fire'; + + @override + String get processingCardLiQuote => + 'With clear brilliance, the great one illumines all directions.'; + + @override + String get processingCardZhenTitle => 'Zhen • The Arousing Thunder'; + + @override + String get processingCardZhenQuote => + 'Shock awakens the heart; composure turns fear into growth.'; + + @override + String get processingCardXunTitle => 'Xun • The Gentle Wind'; + + @override + String get processingCardXunQuote => + 'Gentle penetration furthers progress and helps one meet the right people.'; + + @override + String get processingCardKanTitle => 'Kan • The Abysmal Water'; + + @override + String get processingCardKanQuote => + 'In danger, sincerity and disciplined action carry one through.'; + + @override + String get processingCardGenTitle => 'Gen • Keeping Still Mountain'; + + @override + String get processingCardGenQuote => + 'Stillness at the proper time keeps one centered and steady in place.'; + + @override + String get processingCardKunTitle => 'Kun • The Receptive Earth'; + + @override + String get processingCardKunQuote => + 'The Earth\'s condition is devoted receptivity; the noble one carries all with broad virtue.'; + @override String get ganZhiInfo => 'GanZhi Info'; @@ -643,7 +766,28 @@ class AppLocalizationsEn extends AppLocalizations { String get wuXingWangShuai => 'WuXing Strength'; @override - String get ganZhiKongWang => 'KongWang'; + String get ganZhiKongWang => 'KongWang Info'; + + @override + String get resultPillarColumn => 'Pillar'; + + @override + String get resultYearPillar => 'Year'; + + @override + String get resultMonthPillar => 'Month'; + + @override + String get resultDayPillar => 'Day'; + + @override + String get resultTimePillar => 'Hour'; + + @override + String get resultGanZhiLabel => 'GanZhi'; + + @override + String get resultKongWangLabel => 'KongWang'; @override String get manualScreenTitle => 'Manual Casting'; diff --git a/apps/lib/l10n/app_localizations_zh.dart b/apps/lib/l10n/app_localizations_zh.dart index f4ebb6b..424c7d4 100644 --- a/apps/lib/l10n/app_localizations_zh.dart +++ b/apps/lib/l10n/app_localizations_zh.dart @@ -205,6 +205,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get signNormal => '中下签'; + @override + String get signBad => '下下签'; + @override String get language => '语言'; @@ -349,6 +352,45 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsLogoutConfirmAction => '再次点击确认退出'; + @override + String get settingsEditProfileAction => '编辑'; + + @override + String get settingsEditProfileTitle => '编辑个人信息'; + + @override + String get settingsAvatar => '头像'; + + @override + String get settingsDisplayName => '昵称'; + + @override + String get settingsDisplayNameHint => '请输入昵称'; + + @override + String get settingsDisplayNameRequired => '请输入昵称后再保存'; + + @override + String get settingsBio => '个人简介'; + + @override + String get settingsBioHint => '一句话介绍你自己'; + + @override + String get settingsAvatarPickerHint => '支持 PNG / JPG / WEBP,建议上传清晰正方形头像'; + + @override + String get settingsAvatarChooseFromAlbum => '从相册选择头像'; + + @override + String get settingsAvatarUploading => '上传中...'; + + @override + String get settingsAvatarUploadSuccess => '头像上传成功'; + + @override + String get settingsAvatarPickPermissionHint => '无法打开相册,请在系统设置中允许照片访问权限'; + @override String get settingsLanguageSection => '界面语言'; @@ -551,6 +593,17 @@ class AppLocalizationsZh extends AppLocalizations { @override String get toastCoinInsufficient => '铜钱不足,无法解卦'; + @override + String get divinationCostDialogTitle => '确认开始解卦'; + + @override + String divinationCostDialogBody(int cost, int balance) { + return '本次解卦将消耗 $cost 点数,当前可用 $balance 点数。是否继续?'; + } + + @override + String get divinationCostDialogConfirm => '确认解卦'; + @override String get toastContentCopied => '分享内容已复制'; @@ -604,6 +657,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get resultManualMethod => '手动起卦'; + @override + String get signTypeShangShang => '上上签'; + + @override + String get signTypeZhongShang => '中上签'; + + @override + String get signTypeZhongXia => '中下签'; + + @override + String get signTypeXiaXia => '下下签'; + @override String get resultCopy => '复制'; @@ -620,6 +685,62 @@ class AppLocalizationsZh extends AppLocalizations { @override String get transitionDone => '解卦完成\n点击查看'; + @override + String get processingCardQianTitle => 'Qian • The Creative'; + + @override + String get processingCardQianQuote => + 'The movement of Heaven is full of power; thus the noble one makes himself strong and tireless.'; + + @override + String get processingCardDuiTitle => 'Dui • The Joyous'; + + @override + String get processingCardDuiQuote => + 'Joy grounded in integrity brings openness, harmony, and right expression.'; + + @override + String get processingCardLiTitle => 'Li • The Clinging Fire'; + + @override + String get processingCardLiQuote => + 'With clear brilliance, the great one illumines all directions.'; + + @override + String get processingCardZhenTitle => 'Zhen • The Arousing Thunder'; + + @override + String get processingCardZhenQuote => + 'Shock awakens the heart; composure turns fear into growth.'; + + @override + String get processingCardXunTitle => 'Xun • The Gentle Wind'; + + @override + String get processingCardXunQuote => + 'Gentle penetration furthers progress and helps one meet the right people.'; + + @override + String get processingCardKanTitle => 'Kan • The Abysmal Water'; + + @override + String get processingCardKanQuote => + 'In danger, sincerity and disciplined action carry one through.'; + + @override + String get processingCardGenTitle => 'Gen • Keeping Still Mountain'; + + @override + String get processingCardGenQuote => + 'Stillness at the proper time keeps one centered and steady in place.'; + + @override + String get processingCardKunTitle => 'Kun • The Receptive Earth'; + + @override + String get processingCardKunQuote => + 'The Earth\'s condition is devoted receptivity; the noble one carries all with broad virtue.'; + @override String get ganZhiInfo => '干支信息'; @@ -627,7 +748,28 @@ class AppLocalizationsZh extends AppLocalizations { String get wuXingWangShuai => '五行旺衰'; @override - String get ganZhiKongWang => '干支空亡'; + String get ganZhiKongWang => '空亡信息'; + + @override + String get resultPillarColumn => '四柱'; + + @override + String get resultYearPillar => '年柱'; + + @override + String get resultMonthPillar => '月柱'; + + @override + String get resultDayPillar => '日柱'; + + @override + String get resultTimePillar => '时柱'; + + @override + String get resultGanZhiLabel => '干支'; + + @override + String get resultKongWangLabel => '空亡'; @override String get manualScreenTitle => '手动起卦'; diff --git a/apps/lib/l10n/app_zh.arb b/apps/lib/l10n/app_zh.arb index 87c8d5a..8d514a5 100644 --- a/apps/lib/l10n/app_zh.arb +++ b/apps/lib/l10n/app_zh.arb @@ -77,6 +77,7 @@ "signBest": "上上签", "signGood": "中上签", "signNormal": "中下签", + "signBad": "下下签", "language": "语言", "settingsTitle": "设置", "settingsSectionGeneral": "通用设置", @@ -142,6 +143,19 @@ "settingsCancel": "取消", "settingsLogoutConfirmHint": "再次点击确认退出登录", "settingsLogoutConfirmAction": "再次点击确认退出", + "settingsEditProfileAction": "编辑", + "settingsEditProfileTitle": "编辑个人信息", + "settingsAvatar": "头像", + "settingsDisplayName": "昵称", + "settingsDisplayNameHint": "请输入昵称", + "settingsDisplayNameRequired": "请输入昵称后再保存", + "settingsBio": "个人简介", + "settingsBioHint": "一句话介绍你自己", + "settingsAvatarPickerHint": "支持 PNG / JPG / WEBP,建议上传清晰正方形头像", + "settingsAvatarChooseFromAlbum": "从相册选择头像", + "settingsAvatarUploading": "上传中...", + "settingsAvatarUploadSuccess": "头像上传成功", + "settingsAvatarPickPermissionHint": "无法打开相册,请在系统设置中允许照片访问权限", "settingsLanguageSection": "界面语言", "settingsCoinBalanceLabel": "当前点数", "settingsCoinBalanceValue": "{balance} 点数", @@ -227,6 +241,19 @@ "questionTypeOther": "其他", "toastPleaseInputQuestion": "请输入您想占卜的问题", "toastCoinInsufficient": "铜钱不足,无法解卦", + "divinationCostDialogTitle": "确认开始解卦", + "divinationCostDialogBody": "本次解卦将消耗 {cost} 点数,当前可用 {balance} 点数。是否继续?", + "@divinationCostDialogBody": { + "placeholders": { + "cost": { + "type": "int" + }, + "balance": { + "type": "int" + } + } + }, + "divinationCostDialogConfirm": "确认解卦", "toastContentCopied": "分享内容已复制", "toastContentCopiedWithTitle": "{title}已复制", "@toastContentCopiedWithTitle": { @@ -251,14 +278,41 @@ "resultQuestion": "占卜问题", "resultAutoMethod": "自动起卦", "resultManualMethod": "手动起卦", + "signTypeShangShang": "上上签", + "signTypeZhongShang": "中上签", + "signTypeZhongXia": "中下签", + "signTypeXiaXia": "下下签", "resultCopy": "复制", "resultWarning": "卦象解读结果均由AI生成,仅供娱乐参考,切不可作为商业、医疗等专业领域的决策依据。理性看待卦象,自由掌握人生。", "transitionPreparing": "天机推演中", "transitionDeriving": "正在解卦", "transitionDone": "解卦完成\n点击查看", + "processingCardQianTitle": "Qian • The Creative", + "processingCardQianQuote": "The movement of Heaven is full of power; thus the noble one makes himself strong and tireless.", + "processingCardDuiTitle": "Dui • The Joyous", + "processingCardDuiQuote": "Joy grounded in integrity brings openness, harmony, and right expression.", + "processingCardLiTitle": "Li • The Clinging Fire", + "processingCardLiQuote": "With clear brilliance, the great one illumines all directions.", + "processingCardZhenTitle": "Zhen • The Arousing Thunder", + "processingCardZhenQuote": "Shock awakens the heart; composure turns fear into growth.", + "processingCardXunTitle": "Xun • The Gentle Wind", + "processingCardXunQuote": "Gentle penetration furthers progress and helps one meet the right people.", + "processingCardKanTitle": "Kan • The Abysmal Water", + "processingCardKanQuote": "In danger, sincerity and disciplined action carry one through.", + "processingCardGenTitle": "Gen • Keeping Still Mountain", + "processingCardGenQuote": "Stillness at the proper time keeps one centered and steady in place.", + "processingCardKunTitle": "Kun • The Receptive Earth", + "processingCardKunQuote": "The Earth's condition is devoted receptivity; the noble one carries all with broad virtue.", "ganZhiInfo": "干支信息", "wuXingWangShuai": "五行旺衰", - "ganZhiKongWang": "干支空亡", + "ganZhiKongWang": "空亡信息", + "resultPillarColumn": "四柱", + "resultYearPillar": "年柱", + "resultMonthPillar": "月柱", + "resultDayPillar": "日柱", + "resultTimePillar": "时柱", + "resultGanZhiLabel": "干支", + "resultKongWangLabel": "空亡", "manualScreenTitle": "手动起卦", "manualSelectTime": "选择起卦时间", "manualSpecifyYaoCombo": "指定铜钱字花组合", diff --git a/apps/lib/shared/widgets/app_modal_dialog.dart b/apps/lib/shared/widgets/app_modal_dialog.dart new file mode 100644 index 0000000..fe6c9b5 --- /dev/null +++ b/apps/lib/shared/widgets/app_modal_dialog.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; + +import '../theme/design_tokens.dart'; + +class AppModalDialogAction { + const AppModalDialogAction({ + required this.label, + required this.onPressed, + this.primary = false, + this.destructive = false, + }); + + final String label; + final VoidCallback onPressed; + final bool primary; + final bool destructive; +} + +class AppModalDialog extends StatelessWidget { + const AppModalDialog({ + super.key, + required this.title, + required this.message, + required this.actions, + this.icon, + }); + + final String title; + final String message; + final IconData? icon; + final List actions; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + + return Dialog( + insetPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.xl, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.xl), + ), + child: Container( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.lg, + AppSpacing.lg, + AppSpacing.md, + ), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: colors.outlineVariant), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (icon != null) + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + alignment: Alignment.center, + child: Icon(icon, color: colors.primary, size: 20), + ), + if (icon != null) const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Text( + message, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + height: 1.45, + ), + ), + const SizedBox(height: AppSpacing.lg), + Row( + children: actions + .map((action) { + final child = action.primary + ? FilledButton( + onPressed: action.onPressed, + style: FilledButton.styleFrom( + backgroundColor: action.destructive + ? colors.error + : colors.primary, + foregroundColor: action.destructive + ? colors.onError + : colors.onPrimary, + minimumSize: const Size.fromHeight(44), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppRadius.full, + ), + ), + ), + child: Text(action.label), + ) + : OutlinedButton( + onPressed: action.onPressed, + style: OutlinedButton.styleFrom( + foregroundColor: colors.onSurface, + side: BorderSide(color: colors.outline), + minimumSize: const Size.fromHeight(44), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppRadius.full, + ), + ), + ), + child: Text(action.label), + ); + + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xs, + ), + child: child, + ), + ); + }) + .toList(growable: false), + ), + ], + ), + ), + ); + } +} diff --git a/apps/lib/shared/widgets/divination/divination_terms.dart b/apps/lib/shared/widgets/divination/divination_terms.dart index d5d6991..243727c 100644 --- a/apps/lib/shared/widgets/divination/divination_terms.dart +++ b/apps/lib/shared/widgets/divination/divination_terms.dart @@ -28,6 +28,7 @@ abstract final class DivinationTerms { static const signBest = '上上签'; static const signGood = '中上签'; static const signNormal = '中下签'; + static const signWorst = '下下签'; static const ganZhi = '干支'; static const ganZhiInfo = '干支信息'; diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index 855668a..7b71eda 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: sensors_plus: ^6.1.1 vibration: ^3.1.3 flutter_markdown: ^0.7.7+1 + image_picker: ^1.1.2 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/apps/test/features/divination/divination_result_screen_test.dart b/apps/test/features/divination/divination_result_screen_test.dart index 021f089..18bf956 100644 --- a/apps/test/features/divination/divination_result_screen_test.dart +++ b/apps/test/features/divination/divination_result_screen_test.dart @@ -8,7 +8,7 @@ import 'package:meeyao_qianwen/features/divination/presentation/screens/divinati import 'package:meeyao_qianwen/l10n/app_localizations.dart'; void main() { - testWidgets('result screen shows key sections', (tester) async { + DivinationResultData buildResultData() { final params = DivinationParams( method: DivinationMethod.auto, questionType: QuestionType.health, @@ -17,7 +17,7 @@ void main() { coinBalance: 10, userId: 'u_test', ); - final data = DivinationResultData( + return DivinationResultData( params: params, binaryCode: '101001', changedBinaryCode: '100001', @@ -158,6 +158,10 @@ void main() { ), ], ); + } + + testWidgets('result screen shows key sections', (tester) async { + final data = buildResultData(); await tester.pumpWidget( MaterialApp( @@ -186,4 +190,55 @@ void main() { expect(find.text('○'), findsWidgets); expect(find.text('×'), findsWidgets); }); + + testWidgets('result screen back returns directly to root home', ( + tester, + ) async { + final data = buildResultData(); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + locale: const Locale('zh'), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) { + return Scaffold( + body: Center( + child: FilledButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DivinationResultScreen(data: data), + ), + ); + }, + child: const Text('open_result'), + ), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('open_result')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 1000)); + await tester.pumpAndSettle(); + + expect(find.byType(DivinationResultScreen), findsOneWidget); + + await tester.tap(find.byIcon(Icons.arrow_back_ios_new_rounded)); + await tester.pumpAndSettle(); + + expect(find.text('open_result'), findsOneWidget); + expect(find.byType(DivinationResultScreen), findsNothing); + }); } diff --git a/apps/test/features/divination/divination_screen_test.dart b/apps/test/features/divination/divination_screen_test.dart index d6cb657..197a197 100644 --- a/apps/test/features/divination/divination_screen_test.dart +++ b/apps/test/features/divination/divination_screen_test.dart @@ -34,6 +34,7 @@ void main() { home: DivinationScreen( sessionStore: sessionStore, userId: 'user_test', + onCompleted: (_) async {}, runServiceOverride: runService, ), ), @@ -72,7 +73,11 @@ void main() { GlobalCupertinoLocalizations.delegate, ], supportedLocales: AppLocalizations.supportedLocales, - home: AutoDivinationScreen(params: params, runService: runService), + home: AutoDivinationScreen( + params: params, + runService: runService, + onCompleted: (_) async {}, + ), ), ); @@ -101,6 +106,7 @@ void main() { home: DivinationScreen( sessionStore: sessionStore, userId: 'user_test', + onCompleted: (_) async {}, runServiceOverride: runService, ), ), diff --git a/apps/test/features/divination/manual_divination_screen_test.dart b/apps/test/features/divination/manual_divination_screen_test.dart index 3bcfbf8..d695554 100644 --- a/apps/test/features/divination/manual_divination_screen_test.dart +++ b/apps/test/features/divination/manual_divination_screen_test.dart @@ -35,7 +35,11 @@ void main() { GlobalCupertinoLocalizations.delegate, ], supportedLocales: AppLocalizations.supportedLocales, - home: ManualDivinationScreen(params: params, runService: runService), + home: ManualDivinationScreen( + params: params, + runService: runService, + onCompleted: (_) async {}, + ), ), ); diff --git a/backend/src/core/agentscope/events/store.py b/backend/src/core/agentscope/events/store.py index 89aa68b..986a116 100644 --- a/backend/src/core/agentscope/events/store.py +++ b/backend/src/core/agentscope/events/store.py @@ -135,6 +135,7 @@ class SqlAlchemyEventStore: "result_type", "suggested_actions", "error", + "divination_derived", "ui_hints", ) worker_output_payload: dict[str, object] = {} @@ -187,7 +188,9 @@ class SqlAlchemyEventStore: content=content, model_code=model_code if isinstance(model_code, str) else None, tool_name=tool_name_value, - metadata=metadata_model.model_dump(mode="json", exclude_none=True), + metadata=metadata_model.model_dump( + mode="json", by_alias=True, exclude_none=True + ), input_tokens=input_tokens, output_tokens=output_tokens, cost=cost, @@ -200,7 +203,9 @@ class SqlAlchemyEventStore: visibility_mask=visibility_mask, role=role.value, content=content, - metadata=metadata_model.model_dump(mode="json", exclude_none=True), + metadata=metadata_model.model_dump( + mode="json", by_alias=True, exclude_none=True + ), timestamp=self._resolve_message_timestamp(persisted), ) @@ -272,7 +277,9 @@ class SqlAlchemyEventStore: role=AgentChatMessageRole.TOOL, content=content, tool_name=tool_output.tool_name, - metadata=metadata_model.model_dump(mode="json", exclude_none=True), + metadata=metadata_model.model_dump( + mode="json", by_alias=True, exclude_none=True + ), visibility_mask=visibility_mask, ) await self._append_context_cache_message( @@ -281,7 +288,9 @@ class SqlAlchemyEventStore: visibility_mask=visibility_mask, role=AgentChatMessageRole.TOOL.value, content=content, - metadata=metadata_model.model_dump(mode="json", exclude_none=True), + metadata=metadata_model.model_dump( + mode="json", by_alias=True, exclude_none=True + ), timestamp=self._resolve_message_timestamp(persisted), ) diff --git a/backend/src/core/agentscope/prompts/agent_prompt.py b/backend/src/core/agentscope/prompts/agent_prompt.py index ff439af..9d9cdbb 100644 --- a/backend/src/core/agentscope/prompts/agent_prompt.py +++ b/backend/src/core/agentscope/prompts/agent_prompt.py @@ -60,7 +60,7 @@ def _worker_rules(llm_config: SystemAgentLLMConfig | None) -> list[str]: "[六爻分析流程]", "- 第1步:准确复述用户问题,确认问题类型与诉求焦点。", "- 第2步:围绕用神、世应、动爻、月建日辰、旺衰关系形成核心判断。", - "- 第3步:给出签级,仅允许 上上签 / 中上签 / 中下签。", + "- 第3步:给出签级,仅允许 上上签 / 中上签 / 中下签 / 下下签。", "- 第4步:输出结论与重点,解释外部阻力或有利转机出现条件。", "- 第5步:给出可执行建议,避免空泛正确话。", "- 第6步:提炼关键词,优先四字表达,简洁且可复述。", diff --git a/backend/src/core/agentscope/runtime/runner.py b/backend/src/core/agentscope/runtime/runner.py index e96d887..94fffe4 100644 --- a/backend/src/core/agentscope/runtime/runner.py +++ b/backend/src/core/agentscope/runtime/runner.py @@ -232,8 +232,10 @@ class AgentScopeRunner: pipeline=pipeline, runtime_client_time=runtime_client_time, runtime_mode=runtime_mode, + derived_divination=derived_divination, ) worker_output = worker_output_model.model_validate(worker_result.payload) + worker_output.divination_derived = derived_divination await self._emit_step_event( pipeline=pipeline, run_input=run_input, @@ -255,6 +257,7 @@ class AgentScopeRunner: pipeline: PipelineLike, runtime_client_time: ClientTimeContext | None, runtime_mode: RuntimeMode, + derived_divination: DerivedDivinationData, ) -> StageExecutionResult: tracking_model = self._build_model(stage_config=stage_config) formatter = OpenAIChatFormatter() @@ -290,7 +293,12 @@ class AgentScopeRunner: usage_summary=tracking_model.usage_summary(), ) await emitter.emit_final_text_end( - worker_output=worker_payload.model_dump(mode="json", exclude_none=True), + worker_output={ + **worker_payload.model_dump(mode="json", exclude_none=True), + "divination_derived": derived_divination.model_dump( + mode="json", by_alias=True, exclude_none=True + ), + }, response_metadata=response_metadata, ) return StageExecutionResult( diff --git a/backend/src/core/agentscope/runtime/stage_emitter.py b/backend/src/core/agentscope/runtime/stage_emitter.py index e615a3c..e3171e4 100644 --- a/backend/src/core/agentscope/runtime/stage_emitter.py +++ b/backend/src/core/agentscope/runtime/stage_emitter.py @@ -70,6 +70,7 @@ class PipelineStageEmitter: "suggested_actions": worker_output.get("suggested_actions") or worker_output.get("advice", []), "error": worker_output.get("error"), + "divination_derived": worker_output.get("divination_derived"), **response_metadata, } ui_hints = worker_output.get("ui_hints") diff --git a/backend/src/core/divination/data/gua_catalog.json b/backend/src/core/divination/data/gua_catalog.json new file mode 100644 index 0000000..be11319 --- /dev/null +++ b/backend/src/core/divination/data/gua_catalog.json @@ -0,0 +1,962 @@ +{ + "111111": { + "name": "乾为天", + "binary": "111111", + "upper_name": "乾", + "lower_name": "乾", + "yao_relations": ["子孙", "妻财", "父母", "官鬼", "兄弟", "父母"], + "yao_tigan": ["子", "寅", "辰", "午", "申", "戌"], + "yao_elements": ["水", "木", "土", "火", "金", "土"], + "world_position": 6, + "response_position": 3, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "011111": { + "name": "天风姤", + "binary": "011111", + "upper_name": "乾", + "lower_name": "巽", + "yao_relations": ["父母", "子孙", "兄弟", "官鬼", "兄弟", "父母"], + "yao_tigan": ["丑", "亥", "酉", "午", "申", "戌"], + "yao_elements": ["土", "水", "金", "火", "金", "土"], + "world_position": 1, + "response_position": 4, + "fushen_positions": [1], + "fushen_relations": ["妻财"], + "fushen_tigan": ["寅"], + "fushen_elements": ["木"] + }, + "001111": { + "name": "天山遁", + "binary": "001111", + "upper_name": "乾", + "lower_name": "艮", + "yao_relations": ["父母", "官鬼", "兄弟", "官鬼", "兄弟", "父母"], + "yao_tigan": ["辰", "午", "申", "午", "申", "戌"], + "yao_elements": ["土", "火", "金", "火", "金", "土"], + "world_position": 2, + "response_position": 5, + "fushen_positions": [0, 1], + "fushen_relations": ["子孙", "妻财"], + "fushen_tigan": ["子", "寅"], + "fushen_elements": ["水", "木"] + }, + "000111": { + "name": "天地否", + "binary": "000111", + "upper_name": "乾", + "lower_name": "坤", + "yao_relations": ["父母", "官鬼", "妻财", "官鬼", "兄弟", "父母"], + "yao_tigan": ["未", "巳", "卯", "午", "申", "戌"], + "yao_elements": ["土", "火", "木", "火", "金", "土"], + "world_position": 3, + "response_position": 6, + "fushen_positions": [0], + "fushen_relations": ["子孙"], + "fushen_tigan": ["子"], + "fushen_elements": ["水"] + }, + "000011": { + "name": "风地观", + "binary": "000011", + "upper_name": "巽", + "lower_name": "坤", + "yao_relations": ["父母", "官鬼", "妻财", "父母", "官鬼", "妻财"], + "yao_tigan": ["未", "巳", "卯", "未", "巳", "卯"], + "yao_elements": ["土", "火", "木", "土", "火", "木"], + "world_position": 4, + "response_position": 1, + "fushen_positions": [0, 4], + "fushen_relations": ["子孙", "兄弟"], + "fushen_tigan": ["子", "申"], + "fushen_elements": ["水", "金"] + }, + "000001": { + "name": "山地剥", + "binary": "000001", + "upper_name": "艮", + "lower_name": "坤", + "yao_relations": ["父母", "官鬼", "妻财", "父母", "子孙", "妻财"], + "yao_tigan": ["未", "巳", "卯", "戌", "子", "寅"], + "yao_elements": ["土", "火", "木", "土", "水", "木"], + "world_position": 5, + "response_position": 2, + "fushen_positions": [4], + "fushen_relations": ["兄弟"], + "fushen_tigan": ["申"], + "fushen_elements": ["金"] + }, + "000101": { + "name": "火地晋", + "binary": "000101", + "upper_name": "离", + "lower_name": "坤", + "yao_relations": ["父母", "官鬼", "妻财", "兄弟", "父母", "官鬼"], + "yao_tigan": ["未", "巳", "卯", "酉", "未", "巳"], + "yao_elements": ["土", "火", "木", "金", "土", "火"], + "world_position": 4, + "response_position": 1, + "fushen_positions": [0], + "fushen_relations": ["子孙"], + "fushen_tigan": ["子"], + "fushen_elements": ["水"] + }, + "111101": { + "name": "火天大有", + "binary": "111101", + "upper_name": "离", + "lower_name": "乾", + "yao_relations": ["子孙", "妻财", "父母", "兄弟", "父母", "官鬼"], + "yao_tigan": ["子", "寅", "辰", "酉", "未", "巳"], + "yao_elements": ["水", "木", "土", "金", "土", "火"], + "world_position": 3, + "response_position": 6, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "010010": { + "name": "坎为水", + "binary": "010010", + "upper_name": "坎", + "lower_name": "坎", + "yao_relations": ["子孙", "官鬼", "妻财", "父母", "官鬼", "兄弟"], + "yao_tigan": ["寅", "辰", "午", "申", "戌", "子"], + "yao_elements": ["木", "土", "火", "金", "土", "水"], + "world_position": 6, + "response_position": 3, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "110010": { + "name": "水泽节", + "binary": "110010", + "upper_name": "坎", + "lower_name": "兑", + "yao_relations": ["妻财", "子孙", "官鬼", "父母", "官鬼", "兄弟"], + "yao_tigan": ["巳", "卯", "丑", "申", "戌", "子"], + "yao_elements": ["火", "木", "土", "金", "土", "水"], + "world_position": 1, + "response_position": 4, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "100010": { + "name": "水雷屯", + "binary": "100010", + "upper_name": "坎", + "lower_name": "震", + "yao_relations": ["兄弟", "子孙", "官鬼", "父母", "官鬼", "兄弟"], + "yao_tigan": ["子", "寅", "辰", "申", "戌", "子"], + "yao_elements": ["水", "木", "土", "金", "土", "水"], + "world_position": 2, + "response_position": 5, + "fushen_positions": [2], + "fushen_relations": ["妻财"], + "fushen_tigan": ["午"], + "fushen_elements": ["火"] + }, + "101010": { + "name": "水火既济", + "binary": "101010", + "upper_name": "坎", + "lower_name": "离", + "yao_relations": ["子孙", "官鬼", "兄弟", "父母", "官鬼", "兄弟"], + "yao_tigan": ["卯", "丑", "亥", "申", "戌", "子"], + "yao_elements": ["木", "土", "水", "金", "土", "水"], + "world_position": 3, + "response_position": 6, + "fushen_positions": [2], + "fushen_relations": ["妻财"], + "fushen_tigan": ["午"], + "fushen_elements": ["火"] + }, + "101110": { + "name": "泽火革", + "binary": "101110", + "upper_name": "兑", + "lower_name": "离", + "yao_relations": ["子孙", "官鬼", "兄弟", "兄弟", "父母", "官鬼"], + "yao_tigan": ["卯", "丑", "亥", "亥", "酉", "未"], + "yao_elements": ["木", "土", "水", "水", "金", "土"], + "world_position": 4, + "response_position": 1, + "fushen_positions": [2], + "fushen_relations": ["妻财"], + "fushen_tigan": ["午"], + "fushen_elements": ["火"] + }, + "101100": { + "name": "雷火丰", + "binary": "101100", + "upper_name": "震", + "lower_name": "离", + "yao_relations": ["子孙", "官鬼", "兄弟", "妻财", "父母", "官鬼"], + "yao_tigan": ["卯", "丑", "亥", "午", "申", "戌"], + "yao_elements": ["木", "土", "水", "火", "金", "土"], + "world_position": 5, + "response_position": 2, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "101000": { + "name": "地火明夷", + "binary": "101000", + "upper_name": "坤", + "lower_name": "离", + "yao_relations": ["子孙", "官鬼", "兄弟", "官鬼", "兄弟", "父母"], + "yao_tigan": ["卯", "丑", "亥", "丑", "亥", "酉"], + "yao_elements": ["木", "土", "水", "土", "水", "金"], + "world_position": 4, + "response_position": 1, + "fushen_positions": [2], + "fushen_relations": ["妻财"], + "fushen_tigan": ["午"], + "fushen_elements": ["火"] + }, + "010000": { + "name": "地水师", + "binary": "010000", + "upper_name": "坤", + "lower_name": "坎", + "yao_relations": ["子孙", "官鬼", "妻财", "官鬼", "兄弟", "父母"], + "yao_tigan": ["寅", "辰", "午", "丑", "亥", "酉"], + "yao_elements": ["木", "土", "火", "土", "水", "金"], + "world_position": 3, + "response_position": 6, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "001001": { + "name": "艮为山", + "binary": "001001", + "upper_name": "艮", + "lower_name": "艮", + "yao_relations": ["兄弟", "父母", "子孙", "兄弟", "妻财", "官鬼"], + "yao_tigan": ["辰", "午", "申", "戌", "子", "寅"], + "yao_elements": ["土", "火", "金", "土", "水", "木"], + "world_position": 6, + "response_position": 3, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "101001": { + "name": "山火贲", + "binary": "101001", + "upper_name": "艮", + "lower_name": "离", + "yao_relations": ["官鬼", "兄弟", "妻财", "兄弟", "妻财", "官鬼"], + "yao_tigan": ["卯", "丑", "亥", "戌", "子", "寅"], + "yao_elements": ["木", "土", "水", "土", "水", "木"], + "world_position": 1, + "response_position": 4, + "fushen_positions": [1, 2], + "fushen_relations": ["父母", "子孙"], + "fushen_tigan": ["午", "申"], + "fushen_elements": ["火", "金"] + }, + "111001": { + "name": "山天大畜", + "binary": "111001", + "upper_name": "艮", + "lower_name": "乾", + "yao_relations": ["妻财", "官鬼", "兄弟", "兄弟", "妻财", "官鬼"], + "yao_tigan": ["子", "寅", "辰", "戌", "子", "寅"], + "yao_elements": ["水", "木", "土", "土", "水", "木"], + "world_position": 2, + "response_position": 5, + "fushen_positions": [1, 2], + "fushen_relations": ["父母", "子孙"], + "fushen_tigan": ["午", "申"], + "fushen_elements": ["火", "金"] + }, + "110001": { + "name": "山泽损", + "binary": "110001", + "upper_name": "艮", + "lower_name": "兑", + "yao_relations": ["父母", "官鬼", "兄弟", "兄弟", "妻财", "官鬼"], + "yao_tigan": ["巳", "卯", "丑", "戌", "子", "寅"], + "yao_elements": ["火", "木", "土", "土", "水", "木"], + "world_position": 3, + "response_position": 6, + "fushen_positions": [2], + "fushen_relations": ["子孙"], + "fushen_tigan": ["申"], + "fushen_elements": ["金"] + }, + "110101": { + "name": "火泽睽", + "binary": "110101", + "upper_name": "离", + "lower_name": "兑", + "yao_relations": ["父母", "官鬼", "兄弟", "子孙", "兄弟", "父母"], + "yao_tigan": ["巳", "卯", "丑", "酉", "未", "巳"], + "yao_elements": ["火", "木", "土", "金", "土", "火"], + "world_position": 4, + "response_position": 1, + "fushen_positions": [4], + "fushen_relations": ["妻财"], + "fushen_tigan": ["子"], + "fushen_elements": ["水"] + }, + "110111": { + "name": "天泽履", + "binary": "110111", + "upper_name": "乾", + "lower_name": "兑", + "yao_relations": ["父母", "官鬼", "兄弟", "父母", "子孙", "兄弟"], + "yao_tigan": ["巳", "卯", "丑", "午", "申", "戌"], + "yao_elements": ["火", "木", "土", "火", "金", "土"], + "world_position": 5, + "response_position": 2, + "fushen_positions": [4], + "fushen_relations": ["妻财"], + "fushen_tigan": ["子"], + "fushen_elements": ["水"] + }, + "110011": { + "name": "风泽中孚", + "binary": "110011", + "upper_name": "巽", + "lower_name": "兑", + "yao_relations": ["父母", "官鬼", "兄弟", "兄弟", "父母", "官鬼"], + "yao_tigan": ["巳", "卯", "丑", "未", "巳", "卯"], + "yao_elements": ["火", "木", "土", "土", "火", "木"], + "world_position": 4, + "response_position": 1, + "fushen_positions": [2, 4], + "fushen_relations": ["子孙", "妻财"], + "fushen_tigan": ["申", "子"], + "fushen_elements": ["金", "水"] + }, + "001011": { + "name": "风山渐", + "binary": "001011", + "upper_name": "巽", + "lower_name": "艮", + "yao_relations": ["兄弟", "父母", "子孙", "兄弟", "父母", "官鬼"], + "yao_tigan": ["辰", "午", "申", "未", "巳", "卯"], + "yao_elements": ["土", "火", "金", "土", "火", "木"], + "world_position": 3, + "response_position": 6, + "fushen_positions": [4], + "fushen_relations": ["妻财"], + "fushen_tigan": ["子"], + "fushen_elements": ["水"] + }, + "100100": { + "name": "震为雷", + "binary": "100100", + "upper_name": "震", + "lower_name": "震", + "yao_relations": ["父母", "兄弟", "妻财", "子孙", "官鬼", "妻财"], + "yao_tigan": ["子", "寅", "辰", "午", "申", "戌"], + "yao_elements": ["水", "木", "土", "火", "金", "土"], + "world_position": 6, + "response_position": 3, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "000100": { + "name": "雷地豫", + "binary": "000100", + "upper_name": "震", + "lower_name": "坤", + "yao_relations": ["妻财", "子孙", "兄弟", "子孙", "官鬼", "妻财"], + "yao_tigan": ["未", "巳", "卯", "午", "申", "戌"], + "yao_elements": ["土", "火", "木", "火", "金", "土"], + "world_position": 1, + "response_position": 4, + "fushen_positions": [0], + "fushen_relations": ["父母"], + "fushen_tigan": ["子"], + "fushen_elements": ["水"] + }, + "010100": { + "name": "雷水解", + "binary": "010100", + "upper_name": "震", + "lower_name": "坎", + "yao_relations": ["兄弟", "妻财", "子孙", "子孙", "官鬼", "妻财"], + "yao_tigan": ["寅", "辰", "午", "午", "申", "戌"], + "yao_elements": ["木", "土", "火", "火", "金", "土"], + "world_position": 2, + "response_position": 5, + "fushen_positions": [0], + "fushen_relations": ["父母"], + "fushen_tigan": ["子"], + "fushen_elements": ["水"] + }, + "011100": { + "name": "雷风恒", + "binary": "011100", + "upper_name": "震", + "lower_name": "巽", + "yao_relations": ["妻财", "父母", "官鬼", "子孙", "官鬼", "妻财"], + "yao_tigan": ["丑", "亥", "酉", "午", "申", "戌"], + "yao_elements": ["土", "水", "金", "火", "金", "土"], + "world_position": 3, + "response_position": 6, + "fushen_positions": [1], + "fushen_relations": ["兄弟"], + "fushen_tigan": ["寅"], + "fushen_elements": ["木"] + }, + "011000": { + "name": "地风升", + "binary": "011000", + "upper_name": "坤", + "lower_name": "巽", + "yao_relations": ["妻财", "父母", "官鬼", "妻财", "父母", "官鬼"], + "yao_tigan": ["丑", "亥", "酉", "丑", "亥", "酉"], + "yao_elements": ["土", "水", "金", "土", "水", "金"], + "world_position": 4, + "response_position": 1, + "fushen_positions": [1, 3], + "fushen_relations": ["兄弟", "子孙"], + "fushen_tigan": ["寅", "午"], + "fushen_elements": ["木", "火"] + }, + "011010": { + "name": "水风井", + "binary": "011010", + "upper_name": "坎", + "lower_name": "巽", + "yao_relations": ["妻财", "父母", "官鬼", "官鬼", "妻财", "父母"], + "yao_tigan": ["丑", "亥", "酉", "申", "戌", "子"], + "yao_elements": ["土", "水", "金", "金", "土", "水"], + "world_position": 5, + "response_position": 2, + "fushen_positions": [1, 3], + "fushen_relations": ["兄弟", "子孙"], + "fushen_tigan": ["寅", "午"], + "fushen_elements": ["木", "火"] + }, + "011110": { + "name": "泽风大过", + "binary": "011110", + "upper_name": "兑", + "lower_name": "巽", + "yao_relations": ["妻财", "父母", "官鬼", "父母", "官鬼", "妻财"], + "yao_tigan": ["丑", "亥", "酉", "亥", "酉", "未"], + "yao_elements": ["土", "水", "金", "水", "金", "土"], + "world_position": 4, + "response_position": 1, + "fushen_positions": [1, 3], + "fushen_relations": ["兄弟", "子孙"], + "fushen_tigan": ["寅", "午"], + "fushen_elements": ["木", "火"] + }, + "100110": { + "name": "泽雷随", + "binary": "100110", + "upper_name": "兑", + "lower_name": "震", + "yao_relations": ["父母", "兄弟", "妻财", "父母", "官鬼", "妻财"], + "yao_tigan": ["子", "寅", "辰", "亥", "酉", "未"], + "yao_elements": ["水", "木", "土", "水", "金", "土"], + "world_position": 3, + "response_position": 6, + "fushen_positions": [3], + "fushen_relations": ["子孙"], + "fushen_tigan": ["午"], + "fushen_elements": ["火"] + }, + "011011": { + "name": "巽为风", + "binary": "011011", + "upper_name": "巽", + "lower_name": "巽", + "yao_relations": ["妻财", "父母", "官鬼", "妻财", "子孙", "兄弟"], + "yao_tigan": ["丑", "亥", "酉", "未", "巳", "卯"], + "yao_elements": ["土", "水", "金", "土", "火", "木"], + "world_position": 6, + "response_position": 3, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "111011": { + "name": "风天小畜", + "binary": "111011", + "upper_name": "巽", + "lower_name": "乾", + "yao_relations": ["父母", "兄弟", "妻财", "妻财", "子孙", "兄弟"], + "yao_tigan": ["子", "寅", "辰", "未", "巳", "卯"], + "yao_elements": ["水", "木", "土", "土", "火", "木"], + "world_position": 1, + "response_position": 4, + "fushen_positions": [2], + "fushen_relations": ["官鬼"], + "fushen_tigan": ["酉"], + "fushen_elements": ["金"] + }, + "101011": { + "name": "风火家人", + "binary": "101011", + "upper_name": "巽", + "lower_name": "离", + "yao_relations": ["兄弟", "妻财", "父母", "妻财", "子孙", "兄弟"], + "yao_tigan": ["卯", "丑", "亥", "未", "巳", "卯"], + "yao_elements": ["木", "土", "水", "土", "火", "木"], + "world_position": 2, + "response_position": 5, + "fushen_positions": [2], + "fushen_relations": ["官鬼"], + "fushen_tigan": ["酉"], + "fushen_elements": ["金"] + }, + "100011": { + "name": "风雷益", + "binary": "100011", + "upper_name": "巽", + "lower_name": "震", + "yao_relations": ["父母", "兄弟", "妻财", "妻财", "子孙", "兄弟"], + "yao_tigan": ["子", "寅", "辰", "未", "巳", "卯"], + "yao_elements": ["水", "木", "土", "土", "火", "木"], + "world_position": 3, + "response_position": 6, + "fushen_positions": [2], + "fushen_relations": ["官鬼"], + "fushen_tigan": ["酉"], + "fushen_elements": ["金"] + }, + "100111": { + "name": "天雷无妄", + "binary": "100111", + "upper_name": "乾", + "lower_name": "震", + "yao_relations": ["父母", "兄弟", "妻财", "子孙", "官鬼", "妻财"], + "yao_tigan": ["子", "寅", "辰", "午", "申", "戌"], + "yao_elements": ["水", "木", "土", "火", "金", "土"], + "world_position": 4, + "response_position": 1, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "100101": { + "name": "火雷噬嗑", + "binary": "100101", + "upper_name": "离", + "lower_name": "震", + "yao_relations": ["父母", "兄弟", "妻财", "官鬼", "妻财", "子孙"], + "yao_tigan": ["子", "寅", "辰", "酉", "未", "巳"], + "yao_elements": ["水", "木", "土", "金", "土", "火"], + "world_position": 5, + "response_position": 2, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "100001": { + "name": "山雷颐", + "binary": "100001", + "upper_name": "艮", + "lower_name": "震", + "yao_relations": ["父母", "兄弟", "妻财", "妻财", "父母", "兄弟"], + "yao_tigan": ["子", "寅", "辰", "戌", "子", "寅"], + "yao_elements": ["水", "木", "土", "土", "水", "木"], + "world_position": 4, + "response_position": 1, + "fushen_positions": [2, 4], + "fushen_relations": ["官鬼", "子孙"], + "fushen_tigan": ["酉", "巳"], + "fushen_elements": ["金", "火"] + }, + "011001": { + "name": "山风蛊", + "binary": "011001", + "upper_name": "艮", + "lower_name": "巽", + "yao_relations": ["妻财", "父母", "官鬼", "妻财", "父母", "兄弟"], + "yao_tigan": ["丑", "亥", "酉", "戌", "子", "寅"], + "yao_elements": ["土", "水", "金", "土", "水", "木"], + "world_position": 3, + "response_position": 6, + "fushen_positions": [4], + "fushen_relations": ["子孙"], + "fushen_tigan": ["巳"], + "fushen_elements": ["火"] + }, + "101101": { + "name": "离为火", + "binary": "101101", + "upper_name": "离", + "lower_name": "离", + "yao_relations": ["父母", "子孙", "官鬼", "妻财", "子孙", "兄弟"], + "yao_tigan": ["卯", "丑", "亥", "酉", "未", "巳"], + "yao_elements": ["木", "土", "水", "金", "土", "火"], + "world_position": 6, + "response_position": 3, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "001101": { + "name": "火山旅", + "binary": "001101", + "upper_name": "离", + "lower_name": "艮", + "yao_relations": ["子孙", "兄弟", "妻财", "妻财", "子孙", "兄弟"], + "yao_tigan": ["辰", "午", "申", "酉", "未", "巳"], + "yao_elements": ["土", "火", "金", "金", "土", "火"], + "world_position": 1, + "response_position": 4, + "fushen_positions": [0, 2], + "fushen_relations": ["父母", "官鬼"], + "fushen_tigan": ["卯", "亥"], + "fushen_elements": ["木", "水"] + }, + "011101": { + "name": "火风鼎", + "binary": "011101", + "upper_name": "离", + "lower_name": "巽", + "yao_relations": ["子孙", "官鬼", "妻财", "妻财", "子孙", "兄弟"], + "yao_tigan": ["丑", "亥", "酉", "酉", "未", "巳"], + "yao_elements": ["土", "水", "金", "金", "土", "火"], + "world_position": 2, + "response_position": 5, + "fushen_positions": [0], + "fushen_relations": ["父母"], + "fushen_tigan": ["卯"], + "fushen_elements": ["木"] + }, + "010101": { + "name": "火水未济", + "binary": "010101", + "upper_name": "离", + "lower_name": "坎", + "yao_relations": ["父母", "子孙", "兄弟", "妻财", "子孙", "兄弟"], + "yao_tigan": ["寅", "辰", "午", "酉", "未", "巳"], + "yao_elements": ["木", "土", "火", "金", "土", "火"], + "world_position": 3, + "response_position": 6, + "fushen_positions": [2], + "fushen_relations": ["官鬼"], + "fushen_tigan": ["亥"], + "fushen_elements": ["水"] + }, + "010001": { + "name": "山水蒙", + "binary": "010001", + "upper_name": "艮", + "lower_name": "坎", + "yao_relations": ["父母", "子孙", "兄弟", "子孙", "官鬼", "父母"], + "yao_tigan": ["寅", "辰", "午", "戌", "子", "寅"], + "yao_elements": ["木", "土", "火", "土", "水", "木"], + "world_position": 4, + "response_position": 1, + "fushen_positions": [3], + "fushen_relations": ["妻财"], + "fushen_tigan": ["酉"], + "fushen_elements": ["金"] + }, + "010011": { + "name": "风水涣", + "binary": "010011", + "upper_name": "巽", + "lower_name": "坎", + "yao_relations": ["父母", "子孙", "兄弟", "子孙", "兄弟", "父母"], + "yao_tigan": ["寅", "辰", "午", "未", "巳", "卯"], + "yao_elements": ["木", "土", "火", "土", "火", "木"], + "world_position": 5, + "response_position": 2, + "fushen_positions": [2, 3], + "fushen_relations": ["官鬼", "妻财"], + "fushen_tigan": ["亥", "酉"], + "fushen_elements": ["水", "金"] + }, + "010111": { + "name": "天水讼", + "binary": "010111", + "upper_name": "乾", + "lower_name": "坎", + "yao_relations": ["父母", "子孙", "兄弟", "兄弟", "妻财", "子孙"], + "yao_tigan": ["寅", "辰", "午", "午", "申", "戌"], + "yao_elements": ["木", "土", "火", "火", "金", "土"], + "world_position": 4, + "response_position": 1, + "fushen_positions": [2], + "fushen_relations": ["官鬼"], + "fushen_tigan": ["亥"], + "fushen_elements": ["水"] + }, + "101111": { + "name": "天火同人", + "binary": "101111", + "upper_name": "乾", + "lower_name": "离", + "yao_relations": ["父母", "子孙", "官鬼", "兄弟", "妻财", "子孙"], + "yao_tigan": ["卯", "丑", "亥", "午", "申", "戌"], + "yao_elements": ["木", "土", "水", "火", "金", "土"], + "world_position": 3, + "response_position": 6, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "000000": { + "name": "坤为地", + "binary": "000000", + "upper_name": "坤", + "lower_name": "坤", + "yao_relations": ["兄弟", "父母", "官鬼", "兄弟", "妻财", "子孙"], + "yao_tigan": ["未", "巳", "卯", "丑", "亥", "酉"], + "yao_elements": ["土", "火", "木", "土", "水", "金"], + "world_position": 6, + "response_position": 3, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "100000": { + "name": "地雷复", + "binary": "100000", + "upper_name": "坤", + "lower_name": "震", + "yao_relations": ["妻财", "官鬼", "兄弟", "兄弟", "妻财", "子孙"], + "yao_tigan": ["子", "寅", "辰", "丑", "亥", "酉"], + "yao_elements": ["水", "木", "土", "土", "水", "金"], + "world_position": 1, + "response_position": 4, + "fushen_positions": [1], + "fushen_relations": ["父母"], + "fushen_tigan": ["巳"], + "fushen_elements": ["火"] + }, + "110000": { + "name": "地泽临", + "binary": "110000", + "upper_name": "坤", + "lower_name": "兑", + "yao_relations": ["父母", "官鬼", "兄弟", "兄弟", "妻财", "子孙"], + "yao_tigan": ["巳", "卯", "丑", "丑", "亥", "酉"], + "yao_elements": ["火", "木", "土", "土", "水", "金"], + "world_position": 2, + "response_position": 5, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "111000": { + "name": "地天泰", + "binary": "111000", + "upper_name": "坤", + "lower_name": "乾", + "yao_relations": ["妻财", "官鬼", "兄弟", "兄弟", "妻财", "子孙"], + "yao_tigan": ["子", "寅", "辰", "丑", "亥", "酉"], + "yao_elements": ["水", "木", "土", "土", "水", "金"], + "world_position": 3, + "response_position": 6, + "fushen_positions": [1], + "fushen_relations": ["父母"], + "fushen_tigan": ["巳"], + "fushen_elements": ["火"] + }, + "111100": { + "name": "雷天大壮", + "binary": "111100", + "upper_name": "震", + "lower_name": "乾", + "yao_relations": ["妻财", "官鬼", "兄弟", "父母", "子孙", "兄弟"], + "yao_tigan": ["子", "寅", "辰", "午", "申", "戌"], + "yao_elements": ["水", "木", "土", "火", "金", "土"], + "world_position": 4, + "response_position": 1, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "111110": { + "name": "泽天夬", + "binary": "111110", + "upper_name": "兑", + "lower_name": "乾", + "yao_relations": ["妻财", "官鬼", "兄弟", "妻财", "子孙", "兄弟"], + "yao_tigan": ["子", "寅", "辰", "亥", "酉", "未"], + "yao_elements": ["水", "木", "土", "水", "金", "土"], + "world_position": 5, + "response_position": 2, + "fushen_positions": [1], + "fushen_relations": ["父母"], + "fushen_tigan": ["巳"], + "fushen_elements": ["火"] + }, + "111010": { + "name": "水天需", + "binary": "111010", + "upper_name": "坎", + "lower_name": "乾", + "yao_relations": ["妻财", "官鬼", "兄弟", "子孙", "兄弟", "妻财"], + "yao_tigan": ["子", "寅", "辰", "申", "戌", "子"], + "yao_elements": ["水", "木", "土", "金", "土", "水"], + "world_position": 4, + "response_position": 1, + "fushen_positions": [1], + "fushen_relations": ["父母"], + "fushen_tigan": ["巳"], + "fushen_elements": ["火"] + }, + "000010": { + "name": "水地比", + "binary": "000010", + "upper_name": "坎", + "lower_name": "坤", + "yao_relations": ["兄弟", "父母", "官鬼", "子孙", "兄弟", "妻财"], + "yao_tigan": ["未", "巳", "卯", "申", "戌", "子"], + "yao_elements": ["土", "火", "木", "金", "土", "水"], + "world_position": 3, + "response_position": 6, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "110110": { + "name": "兑为泽", + "binary": "110110", + "upper_name": "兑", + "lower_name": "兑", + "yao_relations": ["官鬼", "妻财", "父母", "子孙", "兄弟", "父母"], + "yao_tigan": ["巳", "卯", "丑", "亥", "酉", "未"], + "yao_elements": ["火", "木", "土", "水", "金", "土"], + "world_position": 6, + "response_position": 3, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "010110": { + "name": "泽水困", + "binary": "010110", + "upper_name": "兑", + "lower_name": "坎", + "yao_relations": ["妻财", "父母", "官鬼", "子孙", "兄弟", "父母"], + "yao_tigan": ["寅", "辰", "午", "亥", "酉", "未"], + "yao_elements": ["木", "土", "火", "水", "金", "土"], + "world_position": 1, + "response_position": 4, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "000110": { + "name": "泽地萃", + "binary": "000110", + "upper_name": "兑", + "lower_name": "坤", + "yao_relations": ["父母", "官鬼", "妻财", "子孙", "兄弟", "父母"], + "yao_tigan": ["未", "巳", "卯", "亥", "酉", "未"], + "yao_elements": ["土", "火", "木", "水", "金", "土"], + "world_position": 2, + "response_position": 5, + "fushen_positions": [], + "fushen_relations": [], + "fushen_tigan": [], + "fushen_elements": [] + }, + "001110": { + "name": "泽山咸", + "binary": "001110", + "upper_name": "兑", + "lower_name": "艮", + "yao_relations": ["父母", "官鬼", "兄弟", "子孙", "兄弟", "父母"], + "yao_tigan": ["辰", "午", "申", "亥", "酉", "未"], + "yao_elements": ["土", "火", "金", "水", "金", "土"], + "world_position": 3, + "response_position": 6, + "fushen_positions": [1], + "fushen_relations": ["妻财"], + "fushen_tigan": ["卯"], + "fushen_elements": ["木"] + }, + "001010": { + "name": "水山蹇", + "binary": "001010", + "upper_name": "坎", + "lower_name": "艮", + "yao_relations": ["父母", "官鬼", "兄弟", "兄弟", "父母", "子孙"], + "yao_tigan": ["辰", "午", "申", "申", "戌", "子"], + "yao_elements": ["土", "火", "金", "金", "土", "水"], + "world_position": 4, + "response_position": 1, + "fushen_positions": [1], + "fushen_relations": ["妻财"], + "fushen_tigan": ["卯"], + "fushen_elements": ["木"] + }, + "001000": { + "name": "地山谦", + "binary": "001000", + "upper_name": "坤", + "lower_name": "艮", + "yao_relations": ["父母", "官鬼", "兄弟", "父母", "子孙", "兄弟"], + "yao_tigan": ["辰", "午", "申", "丑", "亥", "酉"], + "yao_elements": ["土", "火", "金", "土", "水", "金"], + "world_position": 5, + "response_position": 2, + "fushen_positions": [1], + "fushen_relations": ["妻财"], + "fushen_tigan": ["卯"], + "fushen_elements": ["木"] + }, + "001100": { + "name": "雷山小过", + "binary": "001100", + "upper_name": "震", + "lower_name": "艮", + "yao_relations": ["父母", "官鬼", "兄弟", "官鬼", "兄弟", "父母"], + "yao_tigan": ["辰", "午", "申", "午", "申", "戌"], + "yao_elements": ["土", "火", "金", "火", "金", "土"], + "world_position": 4, + "response_position": 1, + "fushen_positions": [1, 3], + "fushen_relations": ["妻财", "子孙"], + "fushen_tigan": ["卯", "亥"], + "fushen_elements": ["木", "水"] + }, + "110100": { + "name": "雷泽归妹", + "binary": "110100", + "upper_name": "震", + "lower_name": "兑", + "yao_relations": ["官鬼", "妻财", "父母", "官鬼", "兄弟", "父母"], + "yao_tigan": ["巳", "卯", "丑", "午", "申", "戌"], + "yao_elements": ["火", "木", "土", "火", "金", "土"], + "world_position": 3, + "response_position": 6, + "fushen_positions": [3], + "fushen_relations": ["子孙"], + "fushen_tigan": ["亥"], + "fushen_elements": ["水"] + } +} diff --git a/backend/src/core/divination/gua_catalog_loader.py b/backend/src/core/divination/gua_catalog_loader.py index ce950f9..471f3d4 100644 --- a/backend/src/core/divination/gua_catalog_loader.py +++ b/backend/src/core/divination/gua_catalog_loader.py @@ -2,8 +2,8 @@ from __future__ import annotations from dataclasses import dataclass from functools import lru_cache +import json from pathlib import Path -import re @dataclass(frozen=True) @@ -23,116 +23,42 @@ class GuaCatalogItem: fushen_elements: tuple[str, ...] -_ENTRY_HEAD_RE = re.compile(r'put\("([01]{6})",\s*GuaInfo\(', re.MULTILINE) -_STRING_FIELD_RE = re.compile(r'\b%s\s*=\s*"([^"]*)"') -_INT_FIELD_RE = re.compile(r"\b%s\s*=\s*(\d+)") -_LIST_STRING_FIELD_RE = re.compile(r"\b%s\s*=\s*listOf\((.*?)\)", re.DOTALL) -_LIST_INT_FIELD_RE = re.compile(r"\b%s\s*=\s*listOf\((.*?)\)", re.DOTALL) - - -def _extract_gua_body(source: str, start_idx: int) -> tuple[str, int]: - depth = 1 - idx = start_idx - while idx < len(source): - ch = source[idx] - if ch == "(": - depth += 1 - elif ch == ")": - depth -= 1 - if depth == 0: - return source[start_idx:idx], idx - idx += 1 - raise ValueError("invalid Guaxiang.kt structure: unmatched parenthesis") - - -def _parse_string_field(body: str, field_name: str) -> str: - match = _STRING_FIELD_RE.pattern % re.escape(field_name) - found = re.search(match, body) - if found is None: - raise ValueError(f"missing field: {field_name}") - return found.group(1) - - -def _parse_int_field(body: str, field_name: str) -> int: - match = _INT_FIELD_RE.pattern % re.escape(field_name) - found = re.search(match, body) - if found is None: - raise ValueError(f"missing field: {field_name}") - return int(found.group(1)) - - -def _parse_list_of_strings( - body: str, field_name: str, *, optional: bool = False -) -> tuple[str, ...]: - if f"{field_name} = emptyList()" in body: - return () - match = _LIST_STRING_FIELD_RE.pattern % re.escape(field_name) - found = re.search(match, body) - if found is None: - if optional: - return () - raise ValueError(f"missing list field: {field_name}") - inner = found.group(1) - values = re.findall(r'"([^"]+)"', inner) - return tuple(values) - - -def _parse_list_of_ints( - body: str, field_name: str, *, optional: bool = False -) -> tuple[int, ...]: - if f"{field_name} = emptyList()" in body: - return () - match = _LIST_INT_FIELD_RE.pattern % re.escape(field_name) - found = re.search(match, body) - if found is None: - if optional: - return () - raise ValueError(f"missing list field: {field_name}") - inner = found.group(1) - values = [int(item.strip()) for item in inner.split(",") if item.strip()] - return tuple(values) - - -def _resolve_guaxiang_file() -> Path: +def _resolve_catalog_file() -> Path: current = Path(__file__).resolve() - root = current.parents[4] - target = ( - root / "old/app/src/main/java/com/example/eryaoapp/screens/result/Guaxiang.kt" - ) + target = current.parent / "data/gua_catalog.json" if not target.exists(): - raise FileNotFoundError(f"Guaxiang.kt not found: {target}") + raise FileNotFoundError(f"gua_catalog.json not found: {target}") return target +def _to_item(raw: object) -> GuaCatalogItem: + if not isinstance(raw, dict): + raise ValueError("invalid gua catalog item: expected object") + return GuaCatalogItem( + name=str(raw["name"]), + binary=str(raw["binary"]), + upper_name=str(raw["upper_name"]), + lower_name=str(raw["lower_name"]), + yao_relations=tuple(str(v) for v in raw["yao_relations"]), + yao_tigan=tuple(str(v) for v in raw["yao_tigan"]), + yao_elements=tuple(str(v) for v in raw["yao_elements"]), + world_position=int(raw["world_position"]), + response_position=int(raw["response_position"]), + fushen_positions=tuple(int(v) for v in raw["fushen_positions"]), + fushen_relations=tuple(str(v) for v in raw["fushen_relations"]), + fushen_tigan=tuple(str(v) for v in raw["fushen_tigan"]), + fushen_elements=tuple(str(v) for v in raw["fushen_elements"]), + ) + + @lru_cache(maxsize=1) def load_gua_catalog() -> dict[str, GuaCatalogItem]: - source = _resolve_guaxiang_file().read_text(encoding="utf-8") - result: dict[str, GuaCatalogItem] = {} - for head in _ENTRY_HEAD_RE.finditer(source): - binary = head.group(1) - body, _ = _extract_gua_body(source, head.end()) - item = GuaCatalogItem( - name=_parse_string_field(body, "name"), - binary=_parse_string_field(body, "binary"), - upper_name=_parse_string_field(body, "upperName"), - lower_name=_parse_string_field(body, "lowerName"), - yao_relations=_parse_list_of_strings(body, "yaoRelations"), - yao_tigan=_parse_list_of_strings(body, "yaoTiGan"), - yao_elements=_parse_list_of_strings(body, "yaoElements"), - world_position=_parse_int_field(body, "worldPosition"), - response_position=_parse_int_field(body, "responsePosition"), - fushen_positions=_parse_list_of_ints( - body, "fushenPositions", optional=True - ), - fushen_relations=_parse_list_of_strings( - body, "fushenRelations", optional=True - ), - fushen_tigan=_parse_list_of_strings(body, "fushenTiGan", optional=True), - fushen_elements=_parse_list_of_strings( - body, "fushenElements", optional=True - ), - ) - result[binary] = item + source = _resolve_catalog_file().read_text(encoding="utf-8") + raw_data = json.loads(source) + if not isinstance(raw_data, dict): + raise ValueError("invalid gua catalog payload") + + result = {str(binary): _to_item(item) for binary, item in raw_data.items()} if len(result) != 64: raise ValueError(f"invalid gua catalog size: {len(result)}") diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py index 110e871..fdaa38f 100644 --- a/backend/src/models/__init__.py +++ b/backend/src/models/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from .agent_chat_message import AgentChatMessage from .agent_chat_session import AgentChatSession +from .auth_user import AuthUser from .llm import Llm from .llm_factory import LlmFactory from .points_ledger import PointsLedger @@ -12,6 +13,7 @@ from .user_points import UserPoints __all__ = [ "AgentChatMessage", "AgentChatSession", + "AuthUser", "Llm", "LlmFactory", "PointsLedger", diff --git a/backend/src/models/auth_user.py b/backend/src/models/auth_user.py new file mode 100644 index 0000000..b786f1a --- /dev/null +++ b/backend/src/models/auth_user.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base + + +class AuthUser(Base): + __tablename__ = "users" + __table_args__ = {"schema": "auth"} + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True) + email: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) diff --git a/backend/src/models/profile.py b/backend/src/models/profile.py index 36e8749..3317f1a 100644 --- a/backend/src/models/profile.py +++ b/backend/src/models/profile.py @@ -7,6 +7,7 @@ from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import Mapped, mapped_column from core.db.base import Base, SoftDeleteMixin, TimestampMixin +from models.auth_user import AuthUser # noqa: F401 class Profile(TimestampMixin, SoftDeleteMixin, Base): diff --git a/backend/src/schemas/agent/runtime_models.py b/backend/src/schemas/agent/runtime_models.py index 7327200..144c277 100644 --- a/backend/src/schemas/agent/runtime_models.py +++ b/backend/src/schemas/agent/runtime_models.py @@ -6,6 +6,7 @@ from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator from schemas.agent.ui_hints import UiHintsPayload +from schemas.domain.divination import DerivedDivinationData class RunStatus(str, Enum): @@ -43,7 +44,7 @@ class WorkerAgentOutputLite(BaseModel): model_config = ConfigDict(extra="forbid") status: RunStatus = RunStatus.SUCCESS - sign_level: Literal["上上签", "中上签", "中下签"] + sign_level: Literal["上上签", "中上签", "中下签", "下下签"] summary: str = Field(min_length=1, max_length=300) conclusion: list[str] = Field(min_length=1, max_length=6) focus_points: list[str] = Field(default_factory=list, max_length=6) @@ -56,6 +57,7 @@ class WorkerAgentOutputLite(BaseModel): key_points: list[str] = Field(default_factory=list, max_length=6) result_type: str = Field(default="structured_payload") suggested_actions: list[str] = Field(default_factory=list, max_length=6) + divination_derived: DerivedDivinationData | None = None @model_validator(mode="after") def sync_compatibility_fields(self) -> WorkerAgentOutputLite: diff --git a/backend/src/v1/agent/repository.py b/backend/src/v1/agent/repository.py index e3bec29..b5dcaa1 100644 --- a/backend/src/v1/agent/repository.py +++ b/backend/src/v1/agent/repository.py @@ -335,6 +335,59 @@ class AgentRepository: return None return str(latest_id) + async def get_latest_assistant_messages_by_user_sessions( + self, + *, + user_id: str, + visibility_mask: int | None = None, + session_limit: int = 50, + ) -> list[dict[str, object]]: + try: + user_uuid = UUID(user_id) + except ValueError as exc: + raise ApiProblemError( + status_code=422, + code="AGENT_USER_ID_INVALID", + detail="Invalid user_id", + ) from exc + + safe_limit = max(int(session_limit), 1) + session_stmt = ( + select(AgentChatSession.id) + .where(AgentChatSession.user_id == user_uuid) + .where(AgentChatSession.deleted_at.is_(None)) + .order_by(AgentChatSession.last_activity_at.desc()) + .limit(safe_limit) + ) + session_ids = (await self._session.execute(session_stmt)).scalars().all() + if not session_ids: + return [] + + snapshots: list[dict[str, object]] = [] + for session_id in session_ids: + message_stmt = ( + select(AgentChatMessage) + .where(AgentChatMessage.session_id == session_id) + .where(AgentChatMessage.deleted_at.is_(None)) + .where(AgentChatMessage.role == AgentChatMessageRole.ASSISTANT) + .order_by(AgentChatMessage.created_at.desc()) + .limit(1) + ) + message_stmt = self._apply_visibility_filter( + stmt=message_stmt, + visibility_mask=visibility_mask, + ) + message = (await self._session.execute(message_stmt)).scalar_one_or_none() + if message is None: + continue + snapshots.append(await self._to_snapshot_message(message)) + + snapshots.sort( + key=lambda item: str(item.get("timestamp") or ""), + reverse=True, + ) + return snapshots + async def get_system_agent_config( self, *, agent_type: str ) -> dict[str, object] | None: diff --git a/backend/src/v1/agent/schemas.py b/backend/src/v1/agent/schemas.py index 0c103e8..c0d3063 100644 --- a/backend/src/v1/agent/schemas.py +++ b/backend/src/v1/agent/schemas.py @@ -7,7 +7,7 @@ from uuid import UUID from pydantic import BaseModel, ConfigDict, Field -from schemas.agent.ui_schema import UiSchemaRenderer +from schemas.domain.divination import DerivedDivinationData class AgentRepositoryLike(Protocol): @@ -31,6 +31,14 @@ class AgentRepositoryLike(Protocol): async def get_latest_session_id_for_user(self, *, user_id: str) -> str | None: ... + async def get_latest_assistant_messages_by_user_sessions( + self, + *, + user_id: str, + visibility_mask: int | None = None, + session_limit: int = 50, + ) -> list[dict[str, object]]: ... + async def persist_user_message( self, *, @@ -187,13 +195,31 @@ class HistoryMessage(BaseModel): default_factory=list, description="Temporary signed URLs for user-attached images", ) - ui_schema: UiSchemaRenderer | None = Field( + + agent_output: HistoryAgentOutput | None = Field( default=None, - description="Compiled UI schema from worker ui_hints for frontend rendering", + description="Structured assistant output for history replay", ) timestamp: str = Field(description="Message creation timestamp in ISO-8601 format") +class HistoryAgentOutput(BaseModel): + model_config = ConfigDict(extra="forbid") + + status: Literal["success", "failed"] | None = None + sign_level: Literal["上上签", "中上签", "中下签", "下下签"] | None = None + summary: str | None = None + conclusion: list[str] = Field(default_factory=list) + focus_points: list[str] = Field(default_factory=list) + advice: list[str] = Field(default_factory=list) + keywords: list[str] = Field(default_factory=list) + answer: str | None = None + key_points: list[str] = Field(default_factory=list) + result_type: str | None = None + suggested_actions: list[str] = Field(default_factory=list) + divination_derived: DerivedDivinationData | None = None + + class HistorySnapshotResponse(BaseModel): """Response schema for GET /api/v1/agent/history""" diff --git a/backend/src/v1/agent/service.py b/backend/src/v1/agent/service.py index 1aedc21..c868b29 100644 --- a/backend/src/v1/agent/service.py +++ b/backend/src/v1/agent/service.py @@ -641,23 +641,37 @@ class AgentService: thread_id: str | None, before: date | None, ) -> HistorySnapshotResponse: - target_thread_id = thread_id - if target_thread_id is None: - target_thread_id = await self._repository.get_latest_session_id_for_user( - user_id=str(current_user.id) + from schemas.domain.chat_message import AgentChatMessage + from v1.agent.utils import convert_message_to_history + from v1.agent.schemas import HistoryMessage + + if thread_id is not None: + return await self.get_history_snapshot( + thread_id=thread_id, + before=before, + current_user=current_user, ) - if target_thread_id is None: - return HistorySnapshotResponse( - scope="history_day", - threadId=None, - day=None, - hasMore=False, - messages=[], + + raw_messages = ( + await self._repository.get_latest_assistant_messages_by_user_sessions( + user_id=str(current_user.id), + visibility_mask=bit_mask(bit=int(SystemVisibilityBit.UI_HISTORY)), + session_limit=50, ) - return await self.get_history_snapshot( - thread_id=target_thread_id, - before=before, - current_user=current_user, + ) + + messages: list[HistoryMessage] = [] + for msg_dict in raw_messages: + msg = AgentChatMessage.model_validate(msg_dict) + converted = convert_message_to_history(msg) + messages.append(HistoryMessage.model_validate(converted)) + + return HistorySnapshotResponse( + scope="history_sessions_latest_assistant", + threadId=None, + day=None, + hasMore=False, + messages=messages, ) def _validate_binary_signed_url( diff --git a/backend/src/v1/agent/utils.py b/backend/src/v1/agent/utils.py index c2344e9..6401b98 100644 --- a/backend/src/v1/agent/utils.py +++ b/backend/src/v1/agent/utils.py @@ -7,7 +7,7 @@ from collections.abc import Callable from typing import Any -from core.agentscope.runtime.ui_compiler import compile as compile_ui_hints +from schemas.agent.runtime_models import AgentOutput from schemas.domain.chat_message import ( AgentChatMessage, AgentChatMessageMetadata, @@ -29,20 +29,20 @@ def convert_message_to_history( 转换规则: - role=user: 读取 metadata.user_message_attachments,转换为 attachments[] - - role=assistant: 读取 metadata.agent_output.ui_hints,编译成 ui_schema + - role=assistant: 读取 metadata.agent_output,输出受控 agent_output """ role = message.role content = message.content metadata = message.metadata attachments: list[dict[str, str]] = [] - ui_schema: dict[str, Any] | None = None + agent_output: dict[str, Any] | None = None if role == "user": attachments = _convert_user_attachments(metadata, get_signed_url_fn) elif role == "assistant": - ui_schema = _compile_worker_ui_hints(metadata) + agent_output = _extract_worker_agent_output(metadata) result: dict[str, Any] = { "id": str(message.id), @@ -55,8 +55,8 @@ def convert_message_to_history( if attachments: result["attachments"] = attachments - if ui_schema: - result["ui_schema"] = ui_schema + if agent_output: + result["agent_output"] = agent_output return result @@ -93,10 +93,10 @@ def _convert_user_attachments( return signed_attachments -def _compile_worker_ui_hints( +def _extract_worker_agent_output( metadata: AgentChatMessageMetadata | dict[str, Any] | None, ) -> dict[str, Any] | None: - """编译 assistant 消息的 agent ui_hints""" + """提取 assistant 消息的结构化 agent_output。""" if not metadata: return None @@ -106,29 +106,52 @@ def _compile_worker_ui_hints( agent_output_data = metadata.get("agent_output") if not agent_output_data: return None - if isinstance(agent_output_data, dict): - raw_ui_schema = agent_output_data.get("ui_schema") - if isinstance(raw_ui_schema, dict): - return raw_ui_schema - from schemas.agent.runtime_models import AgentOutput - try: agent_output = AgentOutput.model_validate(agent_output_data) except Exception: - return None + normalized_payload = _normalize_agent_output_payload(agent_output_data) + try: + agent_output = AgentOutput.model_validate(normalized_payload) + except Exception: + return None if not agent_output: return None - ui_hints = agent_output.ui_hints - if not ui_hints: - return None + payload = agent_output.model_dump(mode="json", by_alias=True, exclude_none=True) + payload.pop("ui_hints", None) + return payload or None - try: - compiled = compile_ui_hints(ui_hints) - return compiled - except Exception: + +def _normalize_agent_output_payload(agent_output_data: Any) -> dict[str, Any] | None: + if not isinstance(agent_output_data, dict): return None + normalized = dict(agent_output_data) + derived = normalized.get("divination_derived") + if isinstance(derived, dict): + normalized["divination_derived"] = _normalize_divination_derived(derived) + return normalized + + +def _normalize_divination_derived(value: Any) -> Any: + if isinstance(value, dict): + result: dict[str, Any] = {} + for key, item in value.items(): + normalized_key = _snake_to_camel(key) + result[normalized_key] = _normalize_divination_derived(item) + return result + if isinstance(value, list): + return [_normalize_divination_derived(item) for item in value] + return value + + +def _snake_to_camel(value: str) -> str: + if "_" not in value: + return value + parts = value.split("_") + if not parts: + return value + return parts[0] + "".join(part[:1].upper() + part[1:] for part in parts[1:]) def mime_to_suffix(mime_type: str) -> str: diff --git a/backend/src/v1/router.py b/backend/src/v1/router.py index c0faffc..daa77e6 100644 --- a/backend/src/v1/router.py +++ b/backend/src/v1/router.py @@ -5,9 +5,11 @@ from fastapi import APIRouter from v1.agent.router import router as agent_router from v1.auth.router import router as auth_router from v1.points.router import router as points_router +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(points_router) +router.include_router(users_router) diff --git a/backend/src/v1/users/dependencies.py b/backend/src/v1/users/dependencies.py index e6d95a4..2097a52 100644 --- a/backend/src/v1/users/dependencies.py +++ b/backend/src/v1/users/dependencies.py @@ -11,6 +11,7 @@ from core.auth.models import CurrentUser from core.db import get_db from core.http.errors import ApiProblemError, problem_payload from services.base.supabase import supabase_service +from v1.users.repository import SQLAlchemyUserRepository from v1.users.service import UserService @@ -53,5 +54,8 @@ def get_user_service( session: Annotated[AsyncSession, Depends(get_db)], user: Annotated[CurrentUser, Depends(get_current_user)], ) -> UserService: - _ = session - return UserService(current_user=user) + return UserService( + current_user=user, + repository=SQLAlchemyUserRepository(session=session), + attachment_storage=supabase_service, + ) diff --git a/backend/src/v1/users/repository.py b/backend/src/v1/users/repository.py index a37f33e..3f3dfa8 100644 --- a/backend/src/v1/users/repository.py +++ b/backend/src/v1/users/repository.py @@ -3,12 +3,35 @@ from __future__ import annotations from dataclasses import dataclass from uuid import UUID +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from models.profile import Profile + @dataclass class SQLAlchemyUserRepository: - session: object + session: AsyncSession - async def get_by_user_ids(self, user_ids: list[UUID]) -> dict[UUID, object]: - _ = self.session - _ = user_ids - return {} + async def get_by_user_ids(self, user_ids: list[UUID]) -> dict[UUID, Profile]: + if not user_ids: + return {} + stmt = ( + select(Profile) + .where(Profile.id.in_(user_ids)) + .where(Profile.deleted_at.is_(None)) + ) + rows = (await self.session.execute(stmt)).scalars().all() + return {row.id: row for row in rows} + + async def get_profile_by_user_id(self, *, user_id: UUID) -> Profile | None: + stmt = ( + select(Profile) + .where(Profile.id == user_id) + .where(Profile.deleted_at.is_(None)) + .limit(1) + ) + return (await self.session.execute(stmt)).scalar_one_or_none() + + async def save(self) -> None: + await self.session.commit() diff --git a/backend/src/v1/users/router.py b/backend/src/v1/users/router.py new file mode 100644 index 0000000..c9ca84c --- /dev/null +++ b/backend/src/v1/users/router.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, File, UploadFile + +from v1.users.dependencies import get_user_service +from v1.users.schemas import ( + AvatarUploadUrlRequest, + AvatarUploadUrlResponse, + ProfileResponse, + UpdateProfileRequest, +) +from v1.users.service import UserService + + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("/me/profile", response_model=ProfileResponse) +async def get_my_profile( + service: UserService = Depends(get_user_service), +) -> ProfileResponse: + return await service.get_profile() + + +@router.patch("/me/profile", response_model=ProfileResponse) +async def update_my_profile( + payload: UpdateProfileRequest, + service: UserService = Depends(get_user_service), +) -> ProfileResponse: + return await service.update_profile(payload) + + +@router.post("/me/avatar/upload-url", response_model=AvatarUploadUrlResponse) +async def create_avatar_upload_url( + payload: AvatarUploadUrlRequest, + service: UserService = Depends(get_user_service), +) -> AvatarUploadUrlResponse: + raw = await service.create_avatar_upload_url(payload) + return AvatarUploadUrlResponse.model_validate(raw) + + +@router.post("/me/avatar", response_model=ProfileResponse) +async def upload_avatar( + file: UploadFile = File(...), + service: UserService = Depends(get_user_service), +) -> ProfileResponse: + return await service.upload_avatar(file) diff --git a/backend/src/v1/users/schemas.py b/backend/src/v1/users/schemas.py new file mode 100644 index 0000000..5930eee --- /dev/null +++ b/backend/src/v1/users/schemas.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class ProfileResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + user_id: str + display_name: str + bio: str | None = None + avatar_path: str | None = None + avatar_url: str | None = None + settings: dict[str, Any] = Field(default_factory=dict) + updated_at: datetime + + +class UpdateProfileRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + display_name: str | None = Field(default=None, max_length=30) + bio: str | None = Field(default=None, max_length=200) + avatar_path: str | None = None + + +class AvatarUploadUrlRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + mime_type: str + file_size: int = Field(gt=0) + ext: str + + +class AvatarUploadUrlResponse(BaseModel): + model_config = ConfigDict(extra="forbid") + + bucket: str + path: str + upload_url: str + expires_in: int diff --git a/backend/src/v1/users/service.py b/backend/src/v1/users/service.py index 0618daa..cf725c3 100644 --- a/backend/src/v1/users/service.py +++ b/backend/src/v1/users/service.py @@ -1,22 +1,291 @@ from __future__ import annotations from dataclasses import dataclass +from pathlib import Path +from uuid import uuid4 +from fastapi import UploadFile +from structlog import get_logger + +from core.config.settings import config from core.auth.models import CurrentUser +from core.http.errors import ApiProblemError, problem_payload +from services.base.supabase import SupabaseService from schemas.shared.user import UserContext +from v1.users.repository import SQLAlchemyUserRepository +from v1.users.schemas import ( + AvatarUploadUrlRequest, + ProfileResponse, + UpdateProfileRequest, +) + + +logger = get_logger("v1.users.service") @dataclass class UserService: current_user: CurrentUser + repository: SQLAlchemyUserRepository + attachment_storage: SupabaseService async def get_me(self) -> UserContext: + profile = await self.repository.get_profile_by_user_id( + user_id=self.current_user.id + ) user_id = str(self.current_user.id) return UserContext( id=user_id, - username=f"user_{user_id[:8]}", + username=profile.username if profile is not None else f"user_{user_id[:8]}", email=self.current_user.email, - avatar_url=None, - bio=None, - settings=None, + avatar_url=profile.avatar_url if profile is not None else None, + bio=profile.bio if profile is not None else None, + settings=profile.settings if profile is not None else None, ) + + async def get_profile(self) -> ProfileResponse: + profile = await self.repository.get_profile_by_user_id( + user_id=self.current_user.id + ) + if profile is None: + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="PROFILE_NOT_FOUND", + detail="Profile not found", + ), + ) + avatar_url = await self._resolve_avatar_url(profile.avatar_url) + return ProfileResponse( + user_id=str(self.current_user.id), + display_name=profile.username, + bio=profile.bio, + avatar_path=profile.avatar_url, + avatar_url=avatar_url, + settings=profile.settings, + updated_at=profile.updated_at, + ) + + async def update_profile(self, payload: UpdateProfileRequest) -> ProfileResponse: + if ( + payload.display_name is None + and payload.bio is None + and payload.avatar_path is None + ): + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="PROFILE_PAYLOAD_INVALID", + detail="At least one profile field must be provided", + ), + ) + + profile = await self.repository.get_profile_by_user_id( + user_id=self.current_user.id + ) + if profile is None: + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="PROFILE_NOT_FOUND", + detail="Profile not found", + ), + ) + + if payload.display_name is not None: + next_name = payload.display_name.strip() + if not next_name: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="PROFILE_PAYLOAD_INVALID", + detail="display_name cannot be empty", + ), + ) + profile.username = next_name + + if payload.bio is not None: + profile.bio = payload.bio.strip() or None + + if payload.avatar_path is not None: + expected_prefix = f"{config.storage.avatar.bucket}/{self.current_user.id}/" + if not payload.avatar_path.startswith(expected_prefix): + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="AVATAR_PATH_SCOPE_INVALID", + detail="Invalid avatar path scope", + ), + ) + profile.avatar_url = payload.avatar_path + + await self.repository.save() + return await self.get_profile() + + async def create_avatar_upload_url( + self, payload: AvatarUploadUrlRequest + ) -> dict[str, str | int]: + max_bytes = config.storage.avatar.max_size_mb * 1024 * 1024 + if payload.file_size > max_bytes: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="AVATAR_FILE_INVALID", + detail="Avatar file size exceeds limit", + ), + ) + + if payload.mime_type not in {"image/png", "image/jpeg", "image/webp"}: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="AVATAR_FILE_INVALID", + detail="Avatar mime type not allowed", + ), + ) + + ext = payload.ext.lower().strip() + if ext not in {"png", "jpg", "jpeg", "webp"}: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="AVATAR_FILE_INVALID", + detail="Avatar extension not allowed", + ), + ) + + bucket = config.storage.avatar.bucket + storage_path = f"{self.current_user.id}/{uuid4()}.{ext}" + try: + upload_url = await self.attachment_storage.create_signed_url( + bucket=bucket, + path=storage_path, + expires_in_seconds=config.storage.signed_url_ttl_seconds, + ) + except Exception as exc: + raise ApiProblemError( + status_code=502, + detail=problem_payload( + code="AVATAR_SIGNED_URL_FAILED", + detail="Failed to generate avatar signed URL", + ), + ) from exc + + return { + "bucket": bucket, + "path": f"{bucket}/{storage_path}", + "upload_url": upload_url, + "expires_in": config.storage.signed_url_ttl_seconds, + } + + async def upload_avatar(self, upload: UploadFile) -> ProfileResponse: + profile = await self.repository.get_profile_by_user_id( + user_id=self.current_user.id + ) + if profile is None: + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="PROFILE_NOT_FOUND", + detail="Profile not found", + ), + ) + + filename = upload.filename or "avatar" + ext = Path(filename).suffix.lower().lstrip(".") + if ext not in {"png", "jpg", "jpeg", "webp"}: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="AVATAR_FILE_INVALID", + detail="Avatar extension not allowed", + ), + ) + + mime_type = (upload.content_type or "").lower().strip() + if mime_type not in {"image/png", "image/jpeg", "image/webp"}: + if ext == "png": + mime_type = "image/png" + elif ext in {"jpg", "jpeg"}: + mime_type = "image/jpeg" + elif ext == "webp": + mime_type = "image/webp" + else: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="AVATAR_FILE_INVALID", + detail="Avatar mime type not allowed", + ), + ) + + content = await upload.read() + if not content: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="AVATAR_FILE_INVALID", + detail="Avatar content is empty", + ), + ) + + max_bytes = config.storage.avatar.max_size_mb * 1024 * 1024 + if len(content) > max_bytes: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="AVATAR_FILE_INVALID", + detail="Avatar file size exceeds limit", + ), + ) + + bucket = config.storage.avatar.bucket + storage_path = f"{self.current_user.id}/{uuid4()}.{ext}" + try: + await self.attachment_storage.upload_bytes( + bucket=bucket, + path=storage_path, + content=content, + content_type=mime_type, + ) + except Exception as exc: + logger.exception( + "Avatar upload to storage failed", + user_id=str(self.current_user.id), + bucket=bucket, + path=storage_path, + mime_type=mime_type, + size_bytes=len(content), + ) + raise ApiProblemError( + status_code=502, + detail=problem_payload( + code="AVATAR_UPLOAD_FAILED", + detail="Failed to upload avatar", + ), + ) from exc + + profile.avatar_url = f"{bucket}/{storage_path}" + await self.repository.save() + return await self.get_profile() + + async def _resolve_avatar_url(self, avatar_path: str | None) -> str | None: + if avatar_path is None: + return None + normalized = avatar_path.strip() + if not normalized: + return None + parts = normalized.split("/", 1) + if len(parts) != 2: + return normalized + bucket, path = parts + if bucket != config.storage.avatar.bucket: + return normalized + try: + return await self.attachment_storage.create_signed_url( + bucket=bucket, + path=path, + expires_in_seconds=config.storage.signed_url_ttl_seconds, + ) + except Exception: + return normalized diff --git a/docs/plans/2026-04-03-datetime-picker-design.md b/docs/plans/2026-04-03-datetime-picker-design.md deleted file mode 100644 index d83a144..0000000 --- a/docs/plans/2026-04-03-datetime-picker-design.md +++ /dev/null @@ -1,76 +0,0 @@ -# 摇卦页面日期时间选择器优化设计 - -## 1. 现状问题 - -1. **硬编码日期格式**:`DateFormat('yyyy年MM月dd日 HH:mm')` 在3处硬编码,未做 l10n - - `auto_divination_screen.dart:353` - - `manual_divination_screen.dart:271` - - `divination_result_screen.dart:455` - -2. **原生 picker 样式简陋**:使用 Material `showDatePicker` + `showTimePicker`,交互体验差 - -## 2. 优化方案 - -### 2.1 自定义底部弹层时间选择器 - -- 使用 `CupertinoDatePicker`(iOS 滚轮样式)替代原生 Material picker -- 底部弹层,带半透明遮罩和圆角动画 -- 日期/时间在同一个 picker 内通过 SegmentedControl 切换 - -### 2.2 Locale-aware 日期格式化 - -使用 `intl` 包实现: -- 中文 locale:`DateFormat.yMd('zh_CN').add_Hm()` → `2026年4月3日 14:30` -- 英文 locale:`DateFormat.yMd('en').add_Hm()` → `4/3/2026 14:30` - -### 2.3 新增 l10n 键值 - -已有键值: -- `autoSelectTime`: "选择起卦时间" / "Select time" -- `manualSelectTime`: "选择起卦时间" / "Select time" -- `divinationModify`: "修改" / "Modify" - -无需新增键值,日期格式完全由 `intl` 包根据 locale 自动处理。 - -## 3. 组件结构 - -``` -apps/lib/shared/widgets/ - └── date_time_picker/ - └── date_time_picker_bottom_sheet.dart # 弹层容器 -``` - -**DateTimePickerBottomSheet** 接口: -```dart -Future showDateTimePickerBottomSheet({ - required BuildContext context, - required DateTime initialDateTime, - DateTime? minDateTime, - DateTime? maxDateTime, -}); -``` - -## 4. 交互流程 - -1. 用户点击"修改"按钮 -2. 底部弹出 `DateTimePickerBottomSheet` -3. SegmentedControl 切换"日期"/"时间"tab -4. Cupertino 滚轮选择值 -5. 点击"确认"关闭弹层并更新状态 - -## 5. 涉及的改动文件 - -### 新建 -- `apps/lib/shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart` - -### 修改 -- `apps/lib/features/divination/presentation/screens/auto_divination_screen.dart` -- `apps/lib/features/divination/presentation/screens/manual_divination_screen.dart` -- `apps/lib/features/divination/presentation/screens/divination_result_screen.dart` - -## 6. 验收标准 - -1. 日期格式跟随系统语言:中文环境显示中文格式,英文环境显示英文格式 -2. 选择器使用 iOS 滚轮样式 -3. 底部弹层带遮罩动画 -4. 原硬编码格式完全移除 diff --git a/docs/plans/2026-04-03-datetime-picker-impl.md b/docs/plans/2026-04-03-datetime-picker-impl.md deleted file mode 100644 index 876f355..0000000 --- a/docs/plans/2026-04-03-datetime-picker-impl.md +++ /dev/null @@ -1,341 +0,0 @@ -# 日期时间选择器优化实现计划 - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** 将摇卦页面的日期时间选择器改为 iOS 滚轮样式,并实现 locale-aware 格式化 - -**Architecture:** 创建共享的 `DateTimePickerBottomSheet` 组件,封装 `CupertinoDatePicker` 和底部弹层交互,替换现有的 `showDatePicker` + `showTimePicker` 调用 - -**Tech Stack:** Flutter, Cupertino widgets, intl package - ---- - -## Task 1: 创建 DateTimePickerBottomSheet 组件 - -**Files:** -- Create: `apps/lib/shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart` - -**Step 1: 创建文件结构和基础代码** - -```dart -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:intl/intl.dart'; - -class DateTimePickerBottomSheet extends StatefulWidget { - const DateTimePickerBottomSheet({ - super.key, - required this.initialDateTime, - this.minDateTime, - this.maxDateTime, - }); - - final DateTime initialDateTime; - final DateTime? minDateTime; - final DateTime? maxDateTime; - - @override - State createState() => _DateTimePickerBottomSheetState(); -} - -class _DateTimePickerBottomSheetState extends State { - late DateTime _selectedDateTime; - int _selectedTab = 0; // 0=日期, 1=时间 - - @override - void initState() { - super.initState(); - _selectedDateTime = widget.initialDateTime; - } - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final locale = Localizations.localeOf(context); - - return Container( - height: 400, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - ), - child: Column( - children: [ - // 顶部栏 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(l10n.cancel), - ), - Text( - l10n.autoSelectTime, - style: Theme.of(context).textTheme.titleMedium, - ), - TextButton( - onPressed: () => Navigator.pop(context, _selectedDateTime), - child: Text(l10n.confirm), - ), - ], - ), - ), - // SegmentedControl 切换日期/时间 - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: CupertinoSlidingSegmentedControl( - groupValue: _selectedTab, - children: { - 0: Text(l10n.dateTab), - 1: Text(l10n.timeTab), - }, - onValueChanged: (value) => setState(() => _selectedTab = value ?? 0), - ), - ), - const SizedBox(height: 16), - // CupertinoDatePicker - Expanded( - child: CupertinoDatePicker( - mode: _selectedTab == 0 - ? CupertinoDatePickerMode.date - : CupertinoDatePickerMode.time, - initialDateTime: _selectedDateTime, - minimumDate: widget.minDateTime, - maximumDate: widget.maxDateTime, - onDateTimeChanged: (DateTime newDateTime) { - setState(() => _selectedDateTime = newDateTime); - }, - ), - ), - ], - ), - ); - } -} - -Future showDateTimePickerBottomSheet({ - required BuildContext context, - required DateTime initialDateTime, - DateTime? minDateTime, - DateTime? maxDateTime, -}) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => DateTimePickerBottomSheet( - initialDateTime: initialDateTime, - minDateTime: minDateTime, - maxDateTime: maxDateTime, - ), - ); -} -``` - -**Step 2: 添加 l10n 键值** - -在 `apps/lib/l10n/app_zh.arb` 添加: -```json -"dateTab": "日期", -"timeTab": "时间", -"confirm": "确认", -"cancel": "取消" -``` - -在 `apps/lib/l10n/app_en.arb` 添加: -```json -"dateTab": "Date", -"timeTab": "Time", -"confirm": "Confirm", -"cancel": "Cancel" -``` - -运行 `flutter gen-l10n` 生成代码 - -**Step 3: Commit** - -```bash -git add apps/lib/shared/widgets/date_time_picker/ apps/lib/l10n/ -git commit -m "feat(divination): add DateTimePickerBottomSheet with iOS wheel style" -``` - ---- - -## Task 2: 修改 auto_divination_screen.dart 使用新选择器 - -**Files:** -- Modify: `apps/lib/features/divination/presentation/screens/auto_divination_screen.dart:208-230` -- Modify: `apps/lib/features/divination/presentation/screens/auto_divination_screen.dart:353` - -**Step 1: 添加 import** - -在文件顶部添加: -```dart -import 'package:shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart'; -``` - -**Step 2: 修改 _pickTime 方法** - -将: -```dart -Future _pickTime() async { - final date = await showDatePicker( - context: context, - initialDate: _selectedTime, - firstDate: DateTime(2000), - lastDate: DateTime(2100), - ); - if (date == null || !mounted) return; - final time = await showTimePicker( - context: context, - initialTime: TimeOfDay.fromDateTime(_selectedTime), - ); - if (time == null || !mounted) return; - setState(() { - _selectedTime = DateTime( - date.year, - date.month, - date.day, - time.hour, - time.minute, - ); - }); -} -``` - -替换为: -```dart -Future _pickTime() async { - final result = await showDateTimePickerBottomSheet( - context: context, - initialDateTime: _selectedTime, - minDateTime: DateTime(2000), - maxDateTime: DateTime(2100), - ); - if (result == null || !mounted) return; - setState(() { - _selectedTime = result; - }); -} -``` - -**Step 3: 修改日期显示格式** - -将: -```dart -DateFormat('yyyy年MM月dd日 HH:mm').format(selectedTime) -``` - -替换为: -```dart -DateFormat.yMd(Localizations.localeOf(context).toString()).add_Hm().format(selectedTime) -``` - -需要添加 import: -```dart -import 'package:intl/intl.dart'; -``` - -**Step 4: Commit** - -```bash -git add apps/lib/features/divination/presentation/screens/auto_divination_screen.dart -git commit -m "feat(divination): use DateTimePickerBottomSheet in auto_divination_screen" -``` - ---- - -## Task 3: 修改 manual_divination_screen.dart 使用新选择器 - -**Files:** -- Modify: `apps/lib/features/divination/presentation/screens/manual_divination_screen.dart:142-168` -- Modify: `apps/lib/features/divination/presentation/screens/manual_divination_screen.dart:271` - -**Step 1: 添加 import** - -```dart -import 'package:shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart'; -import 'package:intl/intl.dart'; -``` - -**Step 2: 修改 _pickTime 方法和日期显示格式** - -同 Task 2 的修改方式 - -**Step 3: Commit** - -```bash -git add apps/lib/features/divination/presentation/screens/manual_divination_screen.dart -git commit -m "feat(divination): use DateTimePickerBottomSheet in manual_divination_screen" -``` - ---- - -## Task 4: 修改 divination_result_screen.dart 的日期格式 - -**Files:** -- Modify: `apps/lib/features/divination/presentation/screens/divination_result_screen.dart:455-457` - -**Step 1: 添加 import** - -```dart -import 'package:intl/intl.dart'; -``` - -**Step 2: 修改日期格式** - -将: -```dart -DateFormat( - 'yyyy年MM月dd日 HH:mm', -).format(data.params.divinationTime), -``` - -替换为: -```dart -DateFormat.yMd(Localizations.localeOf(context).toString()).add_Hm().format(data.params.divinationTime), -``` - -**Step 3: Commit** - -```bash -git add apps/lib/features/divination/presentation/screens/divination_result_screen.dart -git commit -m "refactor(divination): use locale-aware date format in divination_result_screen" -``` - ---- - -## Task 5: 运行验证 - -**Step 1: 生成 l10n** - -```bash -cd apps && flutter gen-l10n -``` - -**Step 2: 运行静态分析** - -```bash -cd apps && flutter analyze -``` - -预期: 无错误 - -**Step 3: 运行相关测试** - -```bash -cd apps && flutter test test/features/divination/ -``` - ---- - -**Plan complete.** Two execution options: - -**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration - -**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints - -**Which approach?** diff --git a/docs/plans/2026-04-03-user-points-chat-design.md b/docs/plans/2026-04-03-user-points-chat-design.md deleted file mode 100644 index 010eddc..0000000 --- a/docs/plans/2026-04-03-user-points-chat-design.md +++ /dev/null @@ -1,505 +0,0 @@ -# Eryao 用户档案/积分/会话数据模型设计 - -日期:2026-04-03 -状态:已确认(待实现) - -## 1. 目标与范围 - -本设计用于 Eryao 后端新增并对齐以下 5 张表: - -1. 用户档案表:`profiles` -2. 用户积分表:`user_points` -3. 积分流水表:`points_ledger` -4. 会话表:`sessions` -5. 对话历史表:`messages` - -来源原则: - -- `profiles`、`sessions`、`messages` 参考并吸收 `social-app` 现有设计。 -- 会话能力按“结构完整复制,但业务先停用 automation”执行。 -- 本文档为设计方案,不包含迁移脚本与代码实现。 - -## 2. 关键确认项 - -### 2.1 profiles.username 不做唯一约束 - -已确认:`profiles.username` **不需要唯一**。 - -设计落地: - -- 不创建 `UNIQUE(username)` 约束。 -- 可保留普通索引 `ix_profiles_username` 以支持检索。 -- 若后续产品要支持“唯一用户名登录/提及”,另行引入唯一标识字段(例如 `handle`)。 - -### 2.2 settings 需要 JSONB 模板 - -`profiles.settings` 使用 `jsonb not null default '{}'::jsonb`,并约定版本化模板: - -```json -{ - "version": 1, - "preferences": { - "interface_language": "zh-CN", - "ai_language": "zh-CN", - "timezone": "Asia/Shanghai", - "country": "CN" - }, - "privacy": { - "profile_visibility": "public", - }, - "notification": { - "push_enabled": true, - }, -} -``` - -说明: - -- `version` 为配置结构版本,后续结构升级通过版本迁移处理。 -- `timezone` 作为运行时时区回退来源之一。 -- `default_runtime_mode` 当前仅允许 `chat` 生效。 - -## 3. 表结构设计 - -## 3.1 profiles(吸收 social-app) - -核心字段: - -- `id uuid primary key`(外键指向 `auth.users(id)`,`on delete cascade`) -- `username varchar(30) not null`(非唯一) -- `avatar_url text null` -- `bio varchar(200) null` -- `settings jsonb not null default '{}'::jsonb` -- `created_at timestamptz not null default now()` -- `updated_at timestamptz not null default now()` -- `deleted_at timestamptz null` - -索引建议: - -- `ix_profiles_username (username)` -- `ix_profiles_settings_gin using gin(settings)` - -初始化建议: - -- 与 `auth.users` 建立注册触发器,自动插入 profile 默认记录。 -- `settings` 初始化值应写入上述模板(而非空对象)。 - -## 3.2 user_points(用户积分账户) - -职责:保存用户积分余额与累计统计,1 用户 1 行。 - -核心字段: - -- `user_id uuid primary key`(FK `auth.users(id)`) -- `balance bigint not null default 0` -- `frozen_balance bigint not null default 0` -- `lifetime_earned bigint not null default 0` -- `lifetime_spent bigint not null default 0` -- `version int not null default 0` -- `updated_at timestamptz not null default now()` - -约束建议: - -- `check (balance >= 0)` -- `check (frozen_balance >= 0)` -- `check (lifetime_earned >= 0)` -- `check (lifetime_spent >= 0)` - -## 3.3 points_ledger(积分流水) - -职责:记录每次积分变更,支持审计、对账、幂等。 - -核心字段: - -- `id uuid primary key` -- `user_id uuid not null`(FK `auth.users(id)`) -- `direction smallint not null`(1 增加,-1 减少) -- `amount bigint not null` -- `balance_after bigint not null` -- `change_type varchar(16) not null`(约束:`register/consume/grant/adjust`) -- `biz_type varchar(16) not null`(约束:当前仅 `chat`) -- `biz_id uuid not null`(当前语义:指向 `sessions.id`) -- `event_id varchar(64) not null` -- `operator_id uuid null` -- `metadata jsonb not null default '{}'::jsonb` -- `created_at timestamptz not null default now()` - -约束与索引建议: - -- `check (amount > 0)` -- `check (direction in (1, -1))` -- `check (change_type in ('register', 'consume', 'grant', 'adjust'))` -- `check (biz_type = 'chat')` -- `foreign key (biz_id) references sessions(id)` -- `unique (user_id, event_id)`(用户维度幂等) -- `index (user_id, created_at desc)` -- `index (biz_type, biz_id)` - -## 3.4 sessions(完整复制结构,先停用 automation) - -来源:`social-app` 的 `sessions` 表结构。 - -核心字段: - -- `id uuid primary key` -- `user_id uuid not null` -- `session_type varchar(20) not null`(结构保留 `chat/automation`) -- `job_id uuid null` -- `title varchar(255) null` -- `status varchar(20) not null` -- `last_activity_at timestamptz not null default now()` -- `message_count int not null default 0` -- `total_tokens int not null default 0` -- `total_cost numeric(12,6) not null default 0` -- `state_snapshot jsonb null` -- `created_at/updated_at/deleted_at` - -业务启用策略(当前阶段): - -- 应用层仅允许 `session_type='chat'`。 -- 应用层要求 `job_id is null`。 -- 数据结构不删减,保留未来 automation 扩展能力。 - -## 3.5 messages(完整复制结构) - -来源:`social-app` 的 `messages` 表结构。 - -核心字段: - -- `id uuid primary key` -- `session_id uuid not null`(FK `sessions(id)`,`on delete cascade`) -- `seq int not null` -- `role varchar(20) not null`(`user/assistant/system/tool`) -- `content text not null` -- `model_code varchar(50) null` -- `tool_name varchar(100) null` -- `input_tokens int not null default 0` -- `output_tokens int not null default 0` -- `cost numeric(12,6) not null default 0` -- `latency_ms int null` -- `visibility_mask bigint not null default 0` -- `metadata jsonb null` -- `created_at/updated_at/deleted_at` - -约束与索引建议: - -- `unique (session_id, seq)` -- `index (session_id)` -- `index (session_id, seq, visibility_mask)` - -## 4. 一致性与事务约定 - -- 积分变更必须在单事务内同时更新:`user_points` + `points_ledger`。 -- 通过 `event_id` 做幂等写保护,避免重试导致重复扣发。 -- `sessions.total_tokens/total_cost/message_count` 作为聚合字段,由写消息流程维护。 - -## 5. 安全与权限 - -- 所有业务写入走后端服务层,不信任客户端传入 `owner_id/user_id`。 -- 表级策略沿用项目约定(RLS + 服务端授权控制)。 -- `metadata/settings` 禁止写入密钥类敏感信息。 - -## 6. 兼容与演进 - -- 本期兼容策略:新增表/字段为主,不做破坏式变更。 -- automation 能力延后启用,仅在业务层放开,不需变更当前 DDL。 -- 若后续需要唯一用户名,应新增独立唯一字段,不直接改造 `username` 历史数据。 - -## 7. 关于“用户实际成本核算表”的结论 - -结论:建议二期引入,不阻塞本期 5 张表上线。 - -理由: - -- 本期已有 `messages.cost` 与 `sessions.total_cost`,可支持展示级统计。 -- 若进入财务对账、补贴结算、重算审计场景,需要独立不可变成本流水表。 - -建议二期最小表:`user_cost_ledger`,记录 provider/model/tokens/raw_cost/billable_cost/event_id。 - -## 8. 字段释义(5 张表逐字段) - -本节作为实施、联调、排障时的字段字典,避免同名字段被不同团队误读。 - -### 8.1 profiles - -- `id`:用户主键,直接对应 `auth.users.id`,生命周期与认证用户绑定。 -- `username`:展示名/昵称,不承担唯一身份语义。 -- `avatar_url`:头像地址。 -- `bio`:用户简介。 -- `settings`:用户配置 JSON,承载语言、时区、隐私、通知等可扩展偏好。 -- `created_at`:记录创建时间。 -- `updated_at`:最近一次更新记录时间。 -- `deleted_at`:软删除时间,`null` 表示有效。 - -### 8.2 user_points - -- `user_id`:积分账户所属用户,1:1 对应 `auth.users.id`。 -- `balance`:当前可计入总余额的积分值(含可用与冻结)。 -- `frozen_balance`:冻结中的积分,暂不可消费。 -- `lifetime_earned`:历史累计获得积分(单调递增)。 -- `lifetime_spent`:历史累计消费积分(单调递增)。 -- `version`:乐观锁版本号,用于并发更新防冲突。 -- `updated_at`:积分账户最近一次变更时间。 - -### 8.3 points_ledger - -- `id`:流水主键。 -- `user_id`:该条积分流水所属用户。 -- `direction`:变更方向,`1` 表示加分,`-1` 表示扣分。 -- `amount`:变更绝对值,始终为正数。 -- `balance_after`:本次变更完成后的账户余额快照。 -- `change_type`:变更分类,仅允许 `register/consume/grant/adjust`。 -- `biz_type`:业务域类型,当前固定 `chat`。 -- `biz_id`:业务侧引用 ID,当前固定引用 `sessions.id`。 -- `event_id`:幂等事件 ID,同一用户下不可重复。 -- `operator_id`:操作人(系统/管理员/服务账号)用户 ID,可空。 -- `metadata`:扩展信息 JSON(上下文参数、备注、来源等)。 -- `created_at`:流水写入时间。 - -### 8.4 sessions - -- `id`:会话主键。 -- `user_id`:会话所属用户。 -- `session_type`:会话类型,当前只启用 `chat`,结构保留 `automation`。 -- `job_id`:自动化任务 ID(当前阶段应为 `null`)。 -- `title`:会话标题。 -- `status`:会话状态(如 active/archived/closed)。 -- `last_activity_at`:最近活动时间,用于排序与回收策略。 -- `message_count`:消息总数聚合值。 -- `total_tokens`:会话累计 token 聚合值。 -- `total_cost`:会话累计成本聚合值。 -- `state_snapshot`:会话状态快照(用于上下文恢复/调试)。 -- `created_at`:创建时间。 -- `updated_at`:更新时间。 -- `deleted_at`:软删除时间。 - -### 8.5 messages - -- `id`:消息主键。 -- `session_id`:所属会话 ID,级联删除。 -- `seq`:会话内消息序号(从小到大单调)。 -- `role`:消息角色(`user/assistant/system/tool`)。 -- `content`:消息主体文本。 -- `model_code`:生成该消息的模型标识。 -- `tool_name`:工具消息对应工具名。 -- `input_tokens`:本条请求输入 token。 -- `output_tokens`:本条响应输出 token。 -- `cost`:本条消息成本。 -- `latency_ms`:本条消息处理耗时(毫秒)。 -- `visibility_mask`:可见性位掩码,用于多视图过滤。 -- `metadata`:扩展信息 JSON。 -- `created_at`:创建时间。 -- `updated_at`:更新时间。 -- `deleted_at`:软删除时间。 - -## 9. 审查结论(重点:user_points / points_ledger) - -结论:当前字段集可支撑一期上线,但若目标是“高并发 + 强审计 + 低误用”,建议在 DDL 层补 4 项硬约束、1 项审计字段,能显著降低后续事故概率。 - -### 9.1 user_points 审查 - -现状可用点: - -- 账户余额、冻结、累计收支、版本号齐全,满足账户模型最小闭环。 -- 非负约束已覆盖核心数值字段,能防止明显脏数据。 - -主要风险与建议: - -1. 缺少 `frozen_balance <= balance` 约束。 - - 风险:可能出现“冻结金额大于总余额”的不合法状态。 - - 建议:新增 `check (frozen_balance <= balance)`。 - -2. 缺少 `created_at`。 - - 风险:无法直接追溯账户初始化时间,审计链不完整。 - - 建议:新增 `created_at timestamptz not null default now()`。 - -3. 并发写依赖应用层版本控制,需明确 SQL 写法。 - - 风险:若更新语句未携带 `version` 条件,可能发生覆盖写。 - - 建议:约定更新模板 `... where user_id=? and version=?`,成功后 `version=version+1`。 - -### 9.2 points_ledger 审查 - -现状可用点: - -- `direction + amount + balance_after + event_id` 组合,已具备审计、幂等、对账基础能力。 -- `(user_id, event_id)` 唯一约束符合“同一用户维度幂等”场景。 - -主要风险与建议: - -1. 缺少 `balance_after >= 0` 约束。 - - 风险:极端并发或逻辑 bug 时可能落负余额快照。 - - 建议:新增 `check (balance_after >= 0)`。 - -2. `operator_id` 未声明外键语义。 - - 风险:排障时难确认操作者主体是否存在。 - - 建议:若业务允许,增加 FK `operator_id -> auth.users(id)`(可 `on delete set null`)。 - -3. `change_type/biz_type` 为自由文本。 - - 风险:枚举漂移(同义不同写)导致统计口径分裂。 - - 建议:通过 `check in (...)` 或字典表约束可选值。 - -4. 缺少“业务发生时间”字段。 - - 风险:`created_at` 仅表示入库时间,异步补偿场景下难对齐业务时序。 - - 建议:二期可加 `occurred_at timestamptz`。 - -### 9.3 一期最低增强清单(建议) - -若只做最小改动,优先加以下 5 项: - -1. `user_points`: `check (frozen_balance <= balance)`。 -2. `user_points`: `created_at timestamptz not null default now()`。 -3. `points_ledger`: `check (balance_after >= 0)`。 -4. `points_ledger`: 明确 `operator_id` 外键策略。 -5. 统一 `change_type/biz_type` 枚举口径(约束或字典表)。 - -## 10. points_ledger 约束模型(定稿草案) - -本节将 `change_type`、`biz_type`、`metadata` 固化为可执行约束,作为后续 DDL 实现依据。 - -### 10.1 change_type / biz_type / biz_id 约束 - -- `change_type`:`register | consume | grant | adjust` -- `biz_type`:当前仅允许 `chat` -- `biz_id`:`uuid not null`,并 `FK -> sessions(id)` - -配套业务约束建议: - -- `register/grant` 必须 `direction = 1` -- `consume` 必须 `direction = -1` -- `adjust` 允许 `direction in (1, -1)` - -建议 SQL(可直接迁移化): - -```sql -alter table points_ledger - add constraint ck_points_ledger_change_type - check (change_type in ('register', 'consume', 'grant', 'adjust')), - add constraint ck_points_ledger_biz_type - check (biz_type = 'chat'), - add constraint ck_points_ledger_direction_by_change_type - check ( - (change_type in ('register', 'grant') and direction = 1) - or (change_type = 'consume' and direction = -1) - or (change_type = 'adjust' and direction in (1, -1)) - ), - add constraint fk_points_ledger_biz_session - foreign key (biz_id) references sessions(id); -``` - -### 10.2 metadata 结构(基于现有 chat 数据的定制模型) - -设计依据(来自当前代码里的真实字段): - -- `messages.metadata` 已稳定存在 `run_id`(见 `AgentChatMessageMetadata.run_id`)。 -- `messages` 表已有计费上下文列:`id/seq/model_code/input_tokens/output_tokens/cost`。 -- chat 业务主键是 `session_id`,本设计里已对应 `points_ledger.biz_id`。 - -因此,`points_ledger.metadata` 不再使用泛化字段,直接锚定现有运行时和消息数据: - -```json -{ - "schema_version": 1, - "reason_code": "REGISTER_WELCOME|CHAT_CONSUME|CHAT_GRANT|CHAT_ADJUST", - "operator_type": "user|system|admin", - "run_id": "string", - "request_id": "string|null", - "charge": { - "message_id": "uuid", - "message_seq": 1, - "model_code": "string", - "input_tokens": 0, - "output_tokens": 0, - "cost": "0.000000" - }, - "ext": {} -} -``` - -字段说明(按现有数据来源): - -- `schema_version`:固定 `1`。 -- `reason_code`:固定业务原因码,不允许自由文本。 -- `operator_type`:与 `operator_id` 搭配使用,表达操作者身份类型。 -- `run_id`:来自 agent 运行主键(`messages.metadata.run_id` 同源)。 -- `request_id`:来自 `X-Request-ID`(可空,排障用)。 -- `charge`:消费/赠金/调整时的“消息快照”,字段全部来自 `messages` 现有列。 -- `ext`:仅允许对象,承载少量扩展审计信息(如工单号)。 - -按 `change_type` 的必填规则(不是通用模板,直接按你当前业务): - -- `register`:必须有 `reason_code/operator_type/run_id`,`charge` 必须不存在。 -- `consume`:必须有 `reason_code/operator_type/run_id/charge`,且 `charge.message_id/message_seq/model_code/input_tokens/output_tokens/cost` 全必填。 -- `grant`:必须有 `reason_code/operator_type/run_id`;若是“按会话补偿赠金”,允许并建议带 `charge`。 -- `adjust`:必须有 `reason_code/operator_type/run_id` 与 `ext.ticket_id`;`charge` 可选。 - -建议 SQL(JSON 约束可执行最小集): - -```sql -alter table points_ledger - add constraint ck_points_ledger_metadata_object - check (jsonb_typeof(metadata) = 'object'), - add constraint ck_points_ledger_metadata_common - check ( - metadata->>'schema_version' = '1' - and metadata->>'reason_code' in ('REGISTER_WELCOME', 'CHAT_CONSUME', 'CHAT_GRANT', 'CHAT_ADJUST') - and metadata->>'operator_type' in ('user', 'system', 'admin') - and coalesce(metadata->>'run_id', '') <> '' - and (not (metadata ? 'ext') or jsonb_typeof(metadata->'ext') = 'object') - ), - add constraint ck_points_ledger_metadata_register_shape - check ( - change_type <> 'register' - or ( - metadata->>'reason_code' = 'REGISTER_WELCOME' - and not (metadata ? 'charge') - ) - ), - add constraint ck_points_ledger_metadata_consume_shape - check ( - change_type <> 'consume' - or ( - metadata->>'reason_code' = 'CHAT_CONSUME' - and (metadata ? 'charge') - and jsonb_typeof(metadata->'charge') = 'object' - and (metadata->'charge' ? 'message_id') - and (metadata->'charge' ? 'message_seq') - and (metadata->'charge' ? 'model_code') - and (metadata->'charge' ? 'input_tokens') - and (metadata->'charge' ? 'output_tokens') - and (metadata->'charge' ? 'cost') - ) - ), - add constraint ck_points_ledger_metadata_adjust_shape - check ( - change_type <> 'adjust' - or ( - metadata->>'reason_code' = 'CHAT_ADJUST' - and (metadata ? 'ext') - and (metadata->'ext' ? 'ticket_id') - and coalesce(metadata #>> '{ext,ticket_id}', '') <> '' - ) - ); -``` - -可选强化(建议二期加触发器,而不是只靠 CHECK): - -- 校验 `metadata.charge.message_id` 真正存在于 `messages.id`,且 `messages.session_id = points_ledger.biz_id`。 -- 校验 `metadata.charge.message_seq` 与该 `message_id` 的真实 `seq` 一致。 - -### 10.3 operator_id 与 created_by/updated_by 是否重复 - -不重复,语义不同: - -- `operator_id`:业务操作者(“谁触发了积分变更”),是业务审计字段。 -- `created_by/updated_by`:数据行审计字段(“谁写了这条数据库记录”)。 - -对 `points_ledger`(不可变流水)而言: - -- `updated_by` 基本无意义(流水不应更新)。 -- `created_by` 常等于服务账号,无法表达真实业务操作者。 -- 因此保留 `operator_id` 是必要的,且建议允许空值(纯系统任务)。 - -推荐实践: - -- `points_ledger`:保留 `operator_id`,不强制引入 `created_by/updated_by`。 -- `user_points`:如项目需要统一审计基类,可在账户表引入 `updated_by`,但不替代流水里的 `operator_id`。 diff --git a/docs/plans/2026-04-05-divination-history-profile-backend-source-plan.md b/docs/plans/2026-04-05-divination-history-profile-backend-source-plan.md new file mode 100644 index 0000000..f281e6c --- /dev/null +++ b/docs/plans/2026-04-05-divination-history-profile-backend-source-plan.md @@ -0,0 +1,241 @@ +# Eryao 解卦历史与个人档案后端单一数据源改造计划 + +日期:2026-04-05 +状态:评审中(未开始编码) + +## 1. 背景与目标 + +当前移动端存在两类不符合目标架构的问题: + +1. 个人档案(昵称、简介、头像)仍有前端本地状态路径,非后端权威数据源。 +2. 首页历史解卦无法稳定由后端快照直接重建结果页,前端被迫做本地兜底。 + +本计划目标: + +- 实现“后端为唯一数据源,前端仅缓存”。 +- 将 `DIVINATION_DERIVED` 的完整结构进入消息 `metadata.agent_output` 并持久化。 +- 历史接口返回可被前端直接解析的结构化 assistant 输出(不再依赖 `ui_schema`)。 +- 个人档案全链路后端化,头像使用 `avatars` bucket。 + +非目标: + +- 本计划不直接提交代码实现。 +- 本计划不包含 UI 视觉细节改稿。 + +## 2. 现状核对(基于仓库代码) + +### 2.1 历史接口与消息转换 + +- 历史接口:`GET /api/v1/agent/history`,定义于 `backend/src/v1/agent/router.py`。 +- 当前转换逻辑在 `backend/src/v1/agent/utils.py`: + - `user` 消息主要输出 `content` 与 `attachments`。 + - `assistant` 消息默认走 `ui_hints -> ui_schema` 编译路径。 +- 历史响应结构 `HistoryMessage` 当前包含 `ui_schema`,不直接暴露结构化 `agent_output`。 + +### 2.2 DIVINATION_DERIVED 与落库断点 + +- 运行时会发出 `DIVINATION_DERIVED`(见 `backend/src/core/agentscope/runtime/runner.py`)。 +- 消息落库由 `backend/src/core/agentscope/events/store.py` 负责。 +- 当前 `TEXT_MESSAGE_END` 持久化字段包含 `sign_level/summary/.../ui_hints`,未包含 `divination` 结构。 +- 结果:历史快照难以完整重建结果页结构。 + +### 2.3 Profile 与头像 + +- 后端配置已有 `storage.avatar.bucket`,默认 `avatars`(`backend/src/core/config/settings.py`)。 +- 当前 `v1` 仅挂载 `auth/agent/points` 路由(`backend/src/v1/router.py`),尚无 profile 专用路由。 + +## 3. 核心设计决策 + +### 决策 A:把 `divination_derived` 放入 `metadata.agent_output` + +- 在 `AgentOutput` 增加字段 `divination_derived`(强类型,禁止裸 `dict`)。 +- 事件落库时把 `DIVINATION_DERIVED` 内容并入 assistant 的 `metadata.agent_output.divination_derived`。 +- 与 `sign_level/summary/advice/...` 同时持久化,形成一条可回放的 assistant 结构化输出。 + +理由: + +- 最小改动复用现有消息表,不新增历史结果表即可满足回放需求。 +- 前端可直接从历史响应解析结果页,避免本地拼装。 + +### 决策 B:历史接口返回 `assistant.agent_output`,移除 `ui_schema` + +- `HistoryMessage` 改为: + - `user`: `content + attachments` + - `assistant`: `content + agent_output` +- `ui_schema` 从接口协议中移除(迁移自通用模块的历史遗留,不在本项目范围)。 + +理由: + +- 减少中间编译层,契约更稳定、语义更清晰。 +- 前端直接消费业务数据,不依赖通用 UI 编译器。 + +### 决策 C:Profile 全后端化 + 头像对象存储 + +- 新增 users/profile API,前端只保留缓存层。 +- 头像上传走预签名 URL,bucket 固定 `avatars`,路径按用户隔离。 + +## 4. 协议与接口计划(先文档,后实现) + +## 4.1 新增/修改协议文档 + +按“协议先行”更新以下文档: + +1. `docs/protocols/divination/divination-run-protocol.md` + - 增补:历史回放时 assistant `agent_output.divination_derived` 的字段契约。 + - 标记:`ui_schema` 已废弃并移除。 +2. 新增:`docs/protocols/profile/profile-protocol.md` + - 定义 profile 读写与头像上传签名协议。 +3. 如涉及错误码新增,更新: + - `docs/protocols/common/http-error-codes.md` + +### 4.2 后端 API 契约(目标) + +#### A. 历史快照(改造) + +- `GET /api/v1/agent/history` +- 响应中 assistant 消息新增(或替换为)`agent_output`: + - `sign_level` + - `summary` + - `conclusion` + - `focus_points` + - `advice` + - `keywords` + - `answer` + - `divination_derived`(完整卦象结构) + +#### B. Profile(新增) + +- `GET /api/v1/users/me/profile` +- `PATCH /api/v1/users/me/profile` +- `POST /api/v1/users/me/avatar/upload-url` +- (可选)`GET /api/v1/users/me/avatar/signed-url` + +#### C. 头像上传约束 + +- bucket 固定:`config.storage.avatar.bucket` +- 路径前缀建议:`avatars/{user_id}/...` +- 文件类型:`image/png|image/jpeg|image/webp` +- 体积上限:`config.storage.avatar.max_size_mb` + +## 5. 数据模型改造计划 + +### 5.1 Runtime 模型 + +- 文件:`backend/src/schemas/agent/runtime_models.py` +- 变更:`AgentOutput` 增加 `divination_derived` 字段(类型复用 `schemas/domain/divination.py`)。 +- 规则:保持 `extra="forbid"`,禁止无类型漂移。 + +### 5.2 事件到落库链路 + +- 文件:`backend/src/core/agentscope/runtime/stage_emitter.py` + - `TEXT_MESSAGE_END` payload 带上 `divination_derived`。 +- 文件:`backend/src/core/agentscope/events/store.py` + - `worker_output_fields` 纳入 `divination_derived` 并写入 `metadata.agent_output`。 + +### 5.3 历史响应转换 + +- 文件:`backend/src/v1/agent/utils.py` + - 删除 `ui_hints -> ui_schema` 编译路径。 + - assistant 消息改为抽取并返回受控 `agent_output`。 +- 文件:`backend/src/v1/agent/schemas.py` + - `HistoryMessage` 改字段定义(去 `ui_schema`,加 `agent_output`)。 + +## 6. 前端消费与缓存策略 + +### 6.1 历史与结果页 + +- 历史列表数据源改为后端 `agent/history`。 +- 点开历史项时: + - 直接解析 `assistant.agent_output.divination_derived` + 解释文本字段。 + - 本地仅做缓存,不做真源 fallback。 + +### 6.2 Profile + +- 设置页资料读取改为 `GET /users/me/profile`。 +- 编辑资料写入 `PATCH /users/me/profile`。 +- 头像更新走 upload-url + 上传 + profile 更新引用路径。 + +### 6.3 点数 + +- 保持后端余额接口作为权威数据源(现有已接)。 +- 前端只做短期缓存,解卦完成后强制 refresh。 + +## 7. 代码清理边界(你关心的“删除通用遗留”) + +原则:先去引用,再删定义,最后删文件,避免误删。 + +分三步: + +1. 第一阶段(本次改造内) + - 删除 `agent/history` 对 `ui_schema` 的输出与依赖。 + - 删除前端对 `ui_schema` 的消费路径(若存在)。 +2. 第二阶段(安全清理) + - 搜索 `schemas/domain` 与 `schemas/agent/ui_hints` 的实际引用。 + - 对“零引用 + 非协议字段”进行清理。 +3. 第三阶段(文档与测试补齐) + - 更新协议文档、错误码、回归测试。 + +备注: + +- 不建议在同一 PR 里“功能改造 + 大规模 schema 删除”,建议拆成两个 PR,降低回归风险。 + +## 8. 测试计划(必须项) + +### 8.1 后端单元/集成 + +1. `TEXT_MESSAGE_END` 持久化:`metadata.agent_output.divination_derived` 落库断言。 +2. `GET /api/v1/agent/history`:assistant 返回 `agent_output`,且不再返回 `ui_schema`。 +3. 历史分页与 owner 校验不回退。 +4. profile API:读写、权限、字段约束、头像路径安全性。 +5. 头像签名 URL:bucket/path/mime/size 约束。 + +### 8.2 前端 + +1. 历史列表从后端数据渲染。 +2. 点击历史项成功进入结果页,字段一致性校验。 +3. profile 页面读写闭环(昵称/简介/头像)。 +4. 点数刷新与缓存失效策略验证。 + +## 9. 风险与回滚 + +主要风险: + +- 历史消息中旧数据可能没有 `divination_derived`,前端需兼容空值。 +- `ui_schema` 下线后,若有隐藏调用方会断。 + +回滚策略: + +- 协议层采用短期双读兼容窗口(仅过渡期): + - 新字段优先;旧字段仅用于读,不再写。 +- 若线上异常,先回滚 history 响应变更,再保持落库新增字段不删。 + +## 10. 实施顺序(最小风险) + +1. 协议文档更新并评审通过。 +2. 后端:`AgentOutput` + 事件落库 + history 响应新增 `agent_output`(先加后切)。 +3. 前端:改消费到 `agent_output`,移除本地真源。 +4. 后端:移除 `ui_schema` 输出。 +5. profile API + 前端接入头像上传。 +6. 清理无用 schema(独立 PR)。 + +## 11. 验收标准(DoD) + +全部满足才算完成: + +1. 解卦后写入的 assistant 消息在 DB 中可见 `metadata.agent_output.divination_derived`。 +2. 首页历史完全来自后端,清空本地缓存后仍可正确展示。 +3. 历史详情可完整还原结果页,不依赖 `ui_schema`。 +4. profile 读写走后端,头像实际落 `avatars` bucket。 +5. 前端不再把 profile/history 作为本地权威数据源。 +6. 协议文档与实现一致,相关测试通过。 + +## 12. GSTACK REVIEW REPORT + +| Review | Trigger | Why | Runs | Status | Findings | +|--------|---------|-----|------|--------|----------| +| Eng Review | `/plan-eng-review` | 锁定架构、契约、测试闭环 | 1 | Done | 确认后端单一数据源方向;建议分阶段移除 `ui_schema` 并将 schema 清理拆分独立 PR | +| CEO Review | `/plan-ceo-review` | 范围与优先级 | 0 | — | — | +| Design Review | `/plan-design-review` | UI/UX 风险 | 0 | — | — | +| DX Review | `/plan-devex-review` | 开发体验风险 | 0 | — | — | + +VERDICT:可以进入实现阶段,但必须先完成协议文档更新并冻结字段契约。 diff --git a/docs/plans/2026-04-05-divination-history-profile-eng-plan.md b/docs/plans/2026-04-05-divination-history-profile-eng-plan.md new file mode 100644 index 0000000..9bacc36 --- /dev/null +++ b/docs/plans/2026-04-05-divination-history-profile-eng-plan.md @@ -0,0 +1,403 @@ +# Eryao 工程计划:历史解卦与个人档案后端化(单一数据源) + +日期:2026-04-05 +状态:规划中(Planning Only) + +## 0. 约束与决策前提 + +本计划基于已确认前提: + +1. 当前无生产兼容压力,旧字段可直接不兼容。 +2. 前端只做缓存层,不做权威数据源。 +3. `ui_schema` 属于通用迁移遗留,不在本项目范围,目标是移除。 +4. 头像存储必须使用 `avatars` bucket(`config.storage.avatar.bucket`)。 + +--- + +## 1. 目标 + +在不引入额外业务表的前提下,完成以下工程目标: + +1. assistant 消息落库时,`metadata.agent_output` 持久化完整 `divination_derived`。 +2. `GET /api/v1/agent/history` 返回前端可直接消费的 `assistant.agent_output`(移除 `ui_schema`)。 +3. 新增 profile 后端 API,前端设置页改为后端读写。 +4. 头像上传改为预签名 + `avatars` bucket,后端校验路径和类型。 + +--- + +## 2. 系统边界与职责 + +### 2.1 边界图 + +```text +[Flutter App] + | Auth Token + v +[API Router v1] + |---- /agent/runs + /agent/history + |---- /users/me/profile + /users/me/avatar/upload-url + v +[Service Layer] + |---- AgentService: 会话、历史、消息转换 + |---- UserProfileService: 档案读写、头像签名 + v +[Repository Layer] + |---- sessions/messages/profiles CRUD + v +[Postgres + Supabase Storage] + |---- messages.metadata_json + |---- profiles + |---- bucket: avatars +``` + +### 2.2 分层职责 + +- Router:参数校验、鉴权入口、RFC7807 错误转换。 +- Service:业务规则与信任边界控制。 +- Repository:纯查询和写入,不做鉴权决策。 +- Schema:协议强类型、禁止松散 dict 漂移。 + +--- + +## 3. 数据流设计 + +## 3.1 解卦写入链路(新增 `divination_derived`) + +```text +POST /agent/runs + -> Runner emit DIVINATION_DERIVED(divination) + -> StageEmitter merge into TEXT_MESSAGE_END payload + -> EventStore picks worker_output_fields + -> metadata.agent_output.divination_derived persisted + -> messages.metadata_json +``` + +### 关键点 + +1. `AgentOutput` 增加 `divination_derived` 强类型字段。 +2. `EventStore` 字段白名单纳入 `divination_derived`。 +3. `extra="forbid"` 保留,防止脏字段入库。 + +## 3.2 历史读取链路(移除 `ui_schema`) + +```text +GET /agent/history + -> AgentService.get_history_snapshot + -> convert_message_to_history + user -> content + attachments + assistant -> content + agent_output + -> HistoryMessage response +``` + +### 关键点 + +1. 停止 `ui_hints -> ui_schema` 编译。 +2. assistant 返回受控 `agent_output` 子集,不透传任意 metadata。 +3. 前端结果页以 `agent_output.divination_derived` 为主数据源。 + +## 3.3 Profile 与头像链路 + +```text +GET /users/me/profile + -> read profiles + +PATCH /users/me/profile + -> validate payload + -> update profiles + +POST /users/me/avatar/upload-url + -> validate mime/size/path + -> create signed upload url (bucket=avatars) +``` + +--- + +## 4. API 契约(冻结版) + +## 4.1 History 响应(目标结构) + +```json +{ + "scope": "history_day", + "threadId": "uuid", + "day": "2026-04-05", + "hasMore": false, + "messages": [ + { + "id": "uuid", + "seq": 12, + "role": "assistant", + "content": "...", + "timestamp": "2026-04-05T12:34:56Z", + "agent_output": { + "sign_level": "中上签", + "summary": "...", + "conclusion": ["..."], + "focus_points": ["..."], + "advice": ["..."], + "keywords": ["..."], + "answer": "...", + "divination_derived": { + "binaryCode": "101001", + "changedBinaryCode": "100001", + "guaName": "...", + "targetGuaName": "...", + "ganzhi": {}, + "yaoInfoList": [] + } + } + } + ] +} +``` + +说明: + +- 本接口不再返回 `ui_schema`。 +- user 消息仍可返回 `attachments`。 + +## 4.2 Profile API + +### `GET /api/v1/users/me/profile` + +```json +{ + "user_id": "uuid", + "display_name": "string", + "bio": "string", + "avatar_path": "avatars/{user_id}/...", + "avatar_url": "https://...", + "updated_at": "..." +} +``` + +### `PATCH /api/v1/users/me/profile` + +请求: + +```json +{ + "display_name": "string<=30", + "bio": "string<=200", + "avatar_path": "avatars/{user_id}/..." +} +``` + +### `POST /api/v1/users/me/avatar/upload-url` + +请求: + +```json +{ + "mime_type": "image/png", + "file_size": 123456, + "ext": "png" +} +``` + +响应: + +```json +{ + "bucket": "avatars", + "path": "avatars/{user_id}/{uuid}.png", + "upload_url": "https://...", + "expires_in": 600 +} +``` + +--- + +## 5. 信任边界与安全规则 + +1. `user_id` 只能取 JWT `sub`,禁止客户端传 owner。 +2. 头像 path 必须前缀匹配:`avatars/{current_user.id}/`。 +3. bucket 必须等于 `config.storage.avatar.bucket`。 +4. mime 白名单:`image/png|image/jpeg|image/webp`。 +5. size 上限:`config.storage.avatar.max_size_mb`。 +6. history 读取严格校验 session owner。 +7. 错误统一 RFC7807 + `code`。 + +--- + +## 6. 失败模式与处理 + +## 6.1 消息落库阶段 + +1. `divination_derived` 校验失败 + - 行为:拒绝写入该字段并记录结构化日志。 + - 错误码:`AGENT_OUTPUT_DIVINATION_INVALID`(新)。 +2. TEXT_MESSAGE_END 缺失关键字段 + - 行为:整条 assistant 消息按失败路径处理,不写半残对象。 + +## 6.2 history 读取阶段 + +1. `agent_output` 缺失或损坏 + - 行为:assistant 消息返回 `content`,并标记 `agent_output=null`。 + - 前端:展示“历史记录不完整”提示,不崩溃。 +2. 非 owner 访问 + - 行为:403,`code=AGENT_SESSION_FORBIDDEN`。 + +## 6.3 头像上传阶段 + +1. bucket/path 越权 + - 422,`AVATAR_PATH_SCOPE_INVALID`。 +2. mime/size 非法 + - 422,`AVATAR_FILE_INVALID`。 +3. storage 签名失败 + - 502,`AVATAR_SIGNED_URL_FAILED`。 + +--- + +## 7. 关键边缘场景 + +1. 用户连续点击“保存资料”两次: + - 以后端最后一次写入为准,前端按钮防抖。 +2. 上传头像成功但 profile 更新失败: + - 前端重试 profile PATCH,不重复上传。 +3. history 返回空列表: + - 前端展示空态,不触发本地假数据。 +4. 助手消息存在但缺 `divination_derived`: + - 卡片可展示摘要,不允许进入完整结果页。 +5. 解卦完成后 history 立即读取: + - 允许短暂读到旧快照,前端做一次重拉。 + +--- + +## 8. 技术取舍 + +### 方案 A(推荐):在现有 messages.metadata 扩展 + +- 优点: + - 最小变更,不新增表。 + - 复用当前会话与历史体系。 +- 缺点: + - metadata 体积增大,需要关注单条消息大小。 + +### 方案 B:新增 `divination_results` 独立表 + +- 优点: + - 结构更纯,查询更明确。 +- 缺点: + - 迁移、回写、关联复杂度明显增加。 + +结论: + +- 当前阶段选 A,满足速度与复杂度平衡。 + +--- + +## 9. 实施切片(按风险顺序) + +### Slice 1:协议与 schema + +1. 更新协议文档:history + profile + 错误码。 +2. 更新 `AgentOutput` 模型字段。 + +### Slice 2:写链路改造 + +1. runner/emitter/store 打通 `divination_derived` 落库。 +2. 增加单元测试与集成测试。 + +### Slice 3:读链路改造 + +1. history 转换改为返回 `agent_output`。 +2. 移除 `ui_schema` 响应字段。 + +### Slice 4:profile API + 头像 + +1. users 路由、service、schema。 +2. 头像 upload-url 接口。 + +### Slice 5:前端切换 + +1. 历史列表/详情改消费后端 `agent_output`。 +2. 设置页改 profile 接口。 +3. 清理本地真源。 + +--- + +## 10. 测试覆盖计划 + +## 10.1 后端测试矩阵 + +### A. AgentOutput 落库 + +1. `divination_derived` 正常写入。 +2. `divination_derived` 非法结构拒绝写入。 + +### B. history 接口 + +1. assistant 返回 `agent_output`。 +2. 响应不含 `ui_schema`。 +3. 非 owner 403。 +4. 空历史返回空数组。 + +### C. profile 接口 + +1. GET 返回当前用户档案。 +2. PATCH 字段边界(空、超长、非法字符)。 +3. 并发 PATCH 最终一致性。 + +### D. avatar upload-url + +1. 合法 mime/size/path 成功签名。 +2. bucket/path 越权失败。 +3. mime/size 超限失败。 +4. storage 异常返回 502 问题体。 + +## 10.2 前端测试矩阵 + +1. history 列表从接口渲染。 +2. 点击历史项进入结果页并解析 `divination_derived`。 +3. profile 读写回显。 +4. 头像上传后刷新显示。 +5. 异常提示(网络失败、数据缺失)不崩溃。 + +--- + +## 11. 可观测性 + +新增日志字段建议: + +1. history 响应统计:`thread_id`, `message_count`, `assistant_with_agent_output_count`。 +2. profile 更新:`user_id`, `updated_fields`。 +3. avatar 签名:`user_id`, `mime_type`, `file_size`, `success/failure_code`。 + +指标建议: + +1. `history_agent_output_missing_rate`。 +2. `avatar_upload_url_failure_rate`。 +3. `profile_patch_error_rate`。 + +--- + +## 12. 风险与回滚 + +### 风险 + +1. 单条 metadata 变大,可能影响查询性能。 +2. 前端解析新结构时存在字段名误配风险。 + +### 回滚 + +1. 若读链路异常,先回滚 history 输出层(保持落库不回滚)。 +2. profile 接口异常时,可临时只读禁写,保护账户信息。 + +--- + +## 13. 验收标准(Done) + +1. 新产生 assistant 消息均含 `metadata.agent_output.divination_derived`。 +2. history 接口返回 `agent_output`,且不再返回 `ui_schema`。 +3. 前端历史页与结果页不依赖本地真源。 +4. profile 读写和头像上传全走后端。 +5. 测试矩阵项全部落地并通过。 + +--- + +## 14. NOT in Scope + +1. 大规模清理 `backend/src/schemas/domain/**`。 +2. 历史数据回填脚本。 +3. 新增独立 `divination_results` 表。 diff --git a/docs/protocols/common/http-error-codes.md b/docs/protocols/common/http-error-codes.md index 201a258..c5d3fd8 100644 --- a/docs/protocols/common/http-error-codes.md +++ b/docs/protocols/common/http-error-codes.md @@ -25,6 +25,23 @@ This document is the source of truth for backend RFC7807 `code` values consumed |---|---:|---|---| | `AGENT_SESSION_RUN_LIMIT_EXCEEDED` | 409 | Session already reached max run count (start + 3 follow-ups) | Show run-limit message and require starting a new session | | `AGENT_DIVINATION_PAYLOAD_REQUIRED` | 422 | Missing required `forwardedProps.divinationPayload` in run request | Prompt user to restart casting flow and resubmit | +| `AGENT_OUTPUT_DIVINATION_INVALID` | 422 | Worker output contains invalid `divination_derived` payload shape | Show generic history parse error and suggest retrying latest run | + +## Profile + +| code | status | meaning | frontend handling | +|---|---:|---|---| +| `PROFILE_PAYLOAD_INVALID` | 422 | Profile update payload invalid (length/type/empty constraints) | Highlight invalid fields and block submit | +| `PROFILE_NOT_FOUND` | 404 | User profile row missing | Show retry and optionally trigger profile bootstrap | + +## Avatar + +| code | status | meaning | frontend handling | +|---|---:|---|---| +| `AVATAR_FILE_INVALID` | 422 | Avatar mime type or size is invalid | Show file validation hint and ask user to pick another image | +| `AVATAR_PATH_SCOPE_INVALID` | 422 | Avatar path does not belong to current user scope | Show generic security error and force refresh | +| `AVATAR_SIGNED_URL_FAILED` | 502 | Backend failed to generate avatar signed upload URL | Show retry toast and keep previous avatar | +| `AVATAR_UPLOAD_FAILED` | 502 | Backend failed to upload avatar bytes to storage | Show retry toast and keep previous avatar | Compatibility strategy: diff --git a/docs/protocols/divination/divination-run-protocol.md b/docs/protocols/divination/divination-run-protocol.md index cbca5ea..a2284e4 100644 --- a/docs/protocols/divination/divination-run-protocol.md +++ b/docs/protocols/divination/divination-run-protocol.md @@ -18,6 +18,7 @@ Protocol verification status: - Submit run: `POST /api/v1/agent/runs` - Stream events: `GET /api/v1/agent/runs/{threadId}/events?runId=...` +- History snapshot: `GET /api/v1/agent/history` ## Run request contract @@ -166,6 +167,73 @@ Frontend should combine: - structural divination data from `DIVINATION_DERIVED` - interpretation text from `TEXT_MESSAGE_END` +## History snapshot contract + +`GET /api/v1/agent/history` is the canonical replay source for frontend history list and result reconstruction. + +### Required response shape + +```json +{ + "scope": "history_day", + "threadId": "uuid|null", + "day": "2026-04-05|null", + "hasMore": false, + "messages": [ + { + "id": "uuid", + "seq": 12, + "role": "assistant", + "content": "...", + "timestamp": "2026-04-05T12:34:56+00:00", + "agent_output": { + "status": "success", + "sign_level": "中上签", + "summary": "...", + "conclusion": ["..."], + "focus_points": ["..."], + "advice": ["..."], + "keywords": ["..."], + "answer": "...", + "key_points": ["..."], + "result_type": "structured_payload", + "suggested_actions": ["..."], + "divination_derived": { + "binaryCode": "101001", + "changedBinaryCode": "100001", + "guaName": "山火贲" + } + } + }, + { + "id": "uuid", + "seq": 11, + "role": "user", + "content": "我最近换工作是否合适?", + "timestamp": "2026-04-05T12:34:12+00:00", + "attachments": [ + { + "mimeType": "image/png", + "url": "https://...signed..." + } + ] + } + ] +} +``` + +Rules: + +- `assistant` message MUST provide `agent_output` when backend has valid worker output metadata. +- `agent_output.divination_derived` uses the same shape as `DIVINATION_DERIVED.divination` payload. +- Frontend reconstructs divination result page from `agent_output` data, not from local mock data. +- `agent_output.sign_level` allowed values: `上上签` / `中上签` / `中下签` / `下下签`. + +### Breaking change note + +- `ui_schema` is removed from history response and is no longer part of this project protocol. +- This repository currently accepts non-backward-compatible protocol evolution (no production compatibility burden). + ## Error contract linkage - All errors use RFC7807 with extension `code` and optional `params`. diff --git a/docs/protocols/profile/profile-protocol.md b/docs/protocols/profile/profile-protocol.md new file mode 100644 index 0000000..9304a9b --- /dev/null +++ b/docs/protocols/profile/profile-protocol.md @@ -0,0 +1,143 @@ +# Profile Protocol (Frontend <-> Backend) + +This document defines the canonical backend contract for user profile read/write and avatar upload signing. + +Protocol verification status: + +- Backend model source: `backend/src/models/profile.py` +- Storage config source: `backend/src/core/config/settings.py` +- Current status: planned + +## Compatibility strategy + +- Current strategy: breaking changes allowed during implementation phase (no production compatibility burden). +- Once production compatibility is required, switch to additive-only evolution. + +## Route overview + +- Get profile: `GET /api/v1/users/me/profile` +- Update profile: `PATCH /api/v1/users/me/profile` +- Create avatar upload url: `POST /api/v1/users/me/avatar/upload-url` +- Upload avatar directly: `POST /api/v1/users/me/avatar` (multipart) + +## Auth and trust boundary + +- All routes require authenticated user context. +- `user_id` is derived from verified JWT `sub`; never accepted from client payload. + +## Profile read contract + +### `GET /api/v1/users/me/profile` + +Response: + +```json +{ + "user_id": "uuid", + "display_name": "string", + "bio": "string|null", + "avatar_path": "avatars/{user_id}/{file}", + "avatar_url": "https://...signed-or-public...", + "settings": { + "version": 1, + "preferences": { + "interface_language": "zh-CN", + "ai_language": "zh-CN", + "timezone": "Asia/Shanghai", + "country": "CN" + }, + "privacy": {}, + "notification": {} + }, + "updated_at": "2026-04-05T12:34:56+00:00" +} +``` + +Mapping note: + +- `display_name` maps to `profiles.username`. +- `avatar_path` is stored in profile layer. +- `avatar_url` is render-ready URL generated from storage strategy. + +## Profile update contract + +### `PATCH /api/v1/users/me/profile` + +Request: + +```json +{ + "display_name": "string(1..30)", + "bio": "string(0..200)", + "avatar_path": "avatars/{user_id}/{file}" +} +``` + +Rules: + +- At least one field must be provided. +- `display_name` must be non-empty after trim. +- `bio` can be empty string and should be normalized to `null` only if agreed by API implementation. +- `avatar_path` must stay in current user prefix: `avatars/{current_user.id}/`. + +Response: + +- Returns the same shape as `GET /users/me/profile`. + +## Avatar upload signing contract + +### `POST /api/v1/users/me/avatar/upload-url` + +Request: + +```json +{ + "mime_type": "image/png|image/jpeg|image/webp", + "file_size": 123456, + "ext": "png|jpg|jpeg|webp" +} +``` + +Response: + +```json +{ + "bucket": "avatars", + "path": "avatars/{user_id}/{uuid}.png", + "upload_url": "https://...signed...", + "expires_in": 600 +} +``` + +Validation rules: + +- `bucket` must equal `config.storage.avatar.bucket`. +- `file_size` must be `>0` and `<= config.storage.avatar.max_size_mb`. +- Only image mime types are allowed. +- Path must be server-generated and never trusted from client. + +## Direct avatar upload contract + +### `POST /api/v1/users/me/avatar` + +Request: + +- `multipart/form-data` +- field name: `file` + +Validation rules: + +- extension must be one of `png|jpg|jpeg|webp` +- mime must map to image type (`image/png|image/jpeg|image/webp`) +- payload size must be `<= config.storage.avatar.max_size_mb` + +Behavior: + +- backend writes avatar bytes to `bucket=config.storage.avatar.bucket` +- backend stores canonical path in profile +- response returns latest profile payload (`ProfileResponse`) + +## Error contract linkage + +- All errors must follow RFC7807 `application/problem+json`. +- `code` values must be registered in `docs/protocols/common/http-error-codes.md`. diff --git a/docs/references/backend-features.md b/docs/references/backend-features.md deleted file mode 100644 index b3e7ad3..0000000 --- a/docs/references/backend-features.md +++ /dev/null @@ -1,366 +0,0 @@ -# 后端服务功能模块 - -## 1. 用户认证模块 (`/auth`) - -### 1.1 发送验证码 -- **路径**: `POST /auth/send-code` -- **功能**: 向用户手机号发送短信验证码 -- **参数**: `phoneNumber` (手机号) -- **依赖**: 阿里云短信服务 - -### 1.2 验证码登录 -- **路径**: `POST /auth/login` -- **功能**: 使用手机号+验证码登录,返回用户信息和Token -- **参数**: `phoneNumber`, `code` -- **返回**: `userId`, `phoneNumber`, `token` - -### 1.3 验证Token -- **路径**: `POST /auth/validate-token` -- **功能**: 验证Token有效性,自动刷新即将过期的Token -- **参数**: `token` - -### 1.4 刷新Token -- **路径**: `POST /auth/refresh-token` -- **功能**: 刷新用户Token -- **参数**: `token` - -### 1.5 注销登录 -- **路径**: `POST /auth/logout` -- **功能**: 删除Token,注销登录 -- **参数**: `token` - ---- - -## 2. 用户资料模块 (`/user`) - -### 2.1 获取用户资料 -- **路径**: `GET /user/profile` -- **参数**: `id` (用户ID) -- **返回**: 昵称、性别、生日、个性签名 - -### 2.2 更新用户资料 -- **路径**: `PUT /user/profile` -- **功能**: 更新用户资料(含敏感词检测) -- **参数**: `id`, `nickname`, `gender`, `birthday`, `signature` - -### 2.3 单独更新昵称 -- **路径**: `PUT /user/nickname` -- **功能**: 更新用户昵称(含敏感词检测) -- **参数**: `userId`, `nickname` - -### 2.4 单独更新签名 -- **路径**: `PUT /user/signature` -- **功能**: 更新用户个性签名(含敏感词检测) -- **参数**: `userId`, `signature` - ---- - -## 3. 铜钱系统模块 (`/coin`) - -### 3.1 查询余额 -- **路径**: `GET /coin/balance` -- **参数**: `userId` - -### 3.2 按手机号查询余额 -- **路径**: `GET /coin/balance/phone` -- **参数**: `phoneNumber` - -### 3.3 消费铜钱 -- **路径**: `POST /coin/consume` -- **参数**: `userId` - -### 3.4 按手机号消费铜钱 -- **路径**: `POST /coin/consume/phone` -- **参数**: `phoneNumber` - -### 3.5 重置余额 -- **路径**: `POST /coin/reset` -- **功能**: 重置用户铜钱余额为0(用于注销) -- **参数**: `userId` - -### 3.6 按手机号重置余额 -- **路径**: `POST /coin/reset/phone` -- **参数**: `phoneNumber` - -### 3.7 增加铜钱 -- **路径**: `POST /coin/increase/phone` -- **参数**: `phoneNumber`, `coinCount` - -### 3.8 同步用户铜钱 -- **路径**: `POST /coin/sync` -- **功能**: 手动触发用户铜钱记录同步 - ---- - -## 4. 支付模块 (`/payment`) - -### 4.1 获取支付宝订单 -- **路径**: `GET /payment/alipay/order` -- **参数**: `userId`, `amount`, `coinCount` -- **返回**: 支付宝支付订单信息 - -### 4.2 支付宝异步通知 -- **路径**: `POST /payment/notify` -- **功能**: 处理支付宝异步回调通知 -- **返回**: `success` 或 `fail` - -### 4.3 更新余额 -- **路径**: `POST /payment/update-balance` -- **功能**: 处理支付结果,更新用户铜钱余额 -- **参数**: `userId`, `orderNo`, `tradeNo`, `amount`, `coinCount`, `status` - ---- - -## 5. 卦象历史模块 (`/divination-history`) - -### 5.1 保存卦象记录 -- **路径**: `POST /divination-history/save` -- **参数**: `userId`, `phoneNumber`, `localRecordId`, `jsonData`, `aiResult`, `questionType`, `question`, `timestamp` - -### 5.2 获取卦象记录 -- **路径**: `POST /divination-history/get` -- **参数**: `phoneNumber`, `questionType` (可选) - -### 5.3 删除卦象记录 -- **路径**: `POST /divination-history/delete` -- **参数**: `phoneNumber`, `localRecordId` 或 `localRecordIds` - -### 5.4 统计记录数量 -- **路径**: `GET /divination-history/count/{phoneNumber}` - -### 5.5 批量软删除 -- **路径**: `POST /divination-history/deactivate-all` -- **功能**: 用户注销时批量软删除卦象记录 -- **参数**: `phoneNumber`, `userId` - ---- - -## 6. 解卦溯源模块 (`/divination`) - -### 6.1 增强解卦 -- **路径**: `POST /divination/enhanced` -- **功能**: 调用DeepSeek API进行解卦,支持用户追踪 -- **参数**: `userId`, `questionType`, `question`, `divinationData` - -### 6.2 查询用户解卦记录 -- **路径**: `GET /divination/records/user/{userId}` -- **参数**: `page`, `size` - -### 6.3 按手机号查询记录 -- **路径**: `GET /divination/records/phone/{phoneNumber}` -- **参数**: `page`, `size` - -### 6.4 按追踪ID查询 -- **路径**: `GET /divination/records/trace/{traceId}` - -### 6.5 时间范围查询 -- **路径**: `GET /divination/records/user/{userId}/daterange` -- **参数**: `startTime`, `endTime`, `page`, `size` - -### 6.6 查询失败记录 -- **路径**: `GET /divination/records/failed` - -### 6.7 查询慢请求 -- **路径**: `GET /divination/records/slow` -- **参数**: `durationMs` (默认10000ms) - -### 6.8 统计用户解卦次数 -- **路径**: `GET /divination/stats/user/{userId}/count` - -### 6.9 统计时间范围内记录数 -- **路径**: `GET /divination/stats/daterange/count` -- **参数**: `startTime`, `endTime` - ---- - -## 7. DeepSeek代理模块 (`/deepseek`) - -### 7.1 AI聊天代理 -- **路径**: `POST /deepseek/chat` -- **功能**: 代理DeepSeek API,自动附加用户信息 -- **参数**: 聊天请求体 - ---- - -## 8. 内容审核模块 (`/content-moderation`) - -### 8.1 检测问题内容 -- **路径**: `POST /content-moderation/check-question` -- **功能**: 检测用户问题是否包含敏感词 -- **参数**: `userId`, `question` - ---- - -## 9. 敏感词管理模块 (`/admin/sensitive-words`) - -### 9.1 获取统计信息 -- **路径**: `GET /admin/sensitive-words/statistics` - -### 9.2 添加敏感词 -- **路径**: `POST /admin/sensitive-words/add` -- **参数**: `word`, `type` - -### 9.3 移除敏感词 -- **路径**: `DELETE /admin/sensitive-words/remove` -- **参数**: `word` - -### 9.4 测试检测 -- **路径**: `POST /admin/sensitive-words/test/nickname` -- **路径**: `POST /admin/sensitive-words/test/signature` -- **路径**: `POST /admin/sensitive-words/test/question` -- **参数**: `content` - ---- - -## 10. 违规记录管理模块 (`/admin/violations`) - -### 10.1 获取违规记录列表 -- **路径**: `GET /admin/violations/list` -- **参数**: `page`, `size`, `userId`, `contentType`, `violationType`, `startTime`, `endTime` - -### 10.2 用户违规统计 -- **路径**: `GET /admin/violations/user/{userId}/stats` - -### 10.3 违规类型统计 -- **路径**: `GET /admin/violations/stats/types` - -### 10.4 高频违规用户 -- **路径**: `GET /admin/violations/frequent-violators` -- **参数**: `days`, `threshold` - -### 10.5 清理违规记录 -- **路径**: `DELETE /admin/violations/cleanup` -- **参数**: `daysToKeep` - -### 10.6 获取违规详情 -- **路径**: `GET /admin/violations/{id}` - ---- - -## 11. 敏感词迁移管理模块 (`/admin/sensitive-word-migration`) - -### 11.1 获取配置状态 -- **路径**: `GET /admin/sensitive-word-migration/status` - -### 11.2 切换服务 -- **路径**: `POST /admin/sensitive-word-migration/switch` -- **功能**: 切换本地词库/阿里云服务 -- **参数**: `useAliyun` - -### 11.3 设置降级策略 -- **路径**: `POST /admin/sensitive-word-migration/fallback` -- **参数**: `enableFallback` - -### 11.4 对比测试 -- **路径**: `POST /admin/sensitive-word-migration/compare` -- **功能**: 对比本地和阿里云检测结果 -- **参数**: `content`, `contentType`, `userId` - -### 11.5 批量对比测试 -- **路径**: `POST /admin/sensitive-word-migration/batch-compare` - -### 11.6 健康检查 -- **路径**: `GET /admin/sensitive-word-migration/health-check` - ---- - -## 12. 通知模块 (`/notifications`) - -### 12.1 获取最新通知 -- **路径**: `GET /notifications/latest` - -### 12.2 获取所有通知 -- **路径**: `GET /notifications/all` - ---- - -## 13. 用户反馈模块 (`/feedback`) - -### 13.1 提交反馈 -- **路径**: `POST /feedback` -- **参数**: `user_id`, `phone_number`, `content` - ---- - -## 14. 版本管理模块 (`/version`) - -### 14.1 检查版本更新 -- **路径**: `POST /version/check` -- **参数**: `clientVersion`, `clientVersionCode` - -### 14.2 获取最新版本 -- **路径**: `GET /version/latest` - ---- - -## 15. 网络访问日志模块 (`/admin/network-logs`) - -### 15.1 按用户查询日志 -- **路径**: `GET /admin/network-logs/user/{userId}` - -### 15.2 按IP查询日志 -- **路径**: `GET /admin/network-logs/ip/{clientIp}` - -### 15.3 按时间范围查询 -- **路径**: `GET /admin/network-logs/time-range` - -### 15.4 查询失败记录 -- **路径**: `GET /admin/network-logs/failed` - -### 15.5 检测可疑IP -- **路径**: `GET /admin/network-logs/suspicious` - -### 15.6 统计IP访问次数 -- **路径**: `GET /admin/network-logs/count/ip` - -### 15.7 清理过期日志 -- **路径**: `DELETE /admin/network-logs/cleanup` - ---- - -## 16. 用户数据管理模块 (`/admin/user-data`) - -### 16.1 同步用户数据 -- **路径**: `POST /admin/user-data/sync` - -### 16.2 验证用户数据 -- **路径**: `GET /admin/user-data/validate/user/{userId}` -- **路径**: `GET /admin/user-data/validate/phone/{phoneNumber}` - -### 16.3 修复数据一致性 -- **路径**: `POST /admin/user-data/fix/{phoneNumber}` - -### 16.4 批量验证 -- **路径**: `POST /admin/user-data/validate/batch` - -### 16.5 测试用户信息 -- **路径**: `GET /admin/user-data/test/user-info/{userId}` - ---- - -## 17. 数据清理模块 (`/admin/data-cleanup`) - -### 17.1 清理所有表 -- **路径**: `DELETE /admin/data-cleanup/all` - -### 17.2 清理验证码 -- **路径**: `DELETE /admin/data-cleanup/verification-codes` - -### 17.3 清理支付记录 -- **路径**: `DELETE /admin/data-cleanup/payment-records` - -### 17.4 清理反馈记录 -- **路径**: `DELETE /admin/data-cleanup/feedback` - ---- - -## 第三方服务集成 - -| 服务 | 用途 | 配置 | -|------|------|------| -| 阿里云短信 | 发送验证码 | `aliyun.sms.*` | -| 阿里云内容安全 | 敏感词检测 | `aliyun.content-security.*` | -| DeepSeek API | AI解卦/聊天 | `thirdparty.deepseek.api-key` | -| 支付宝 | 支付充值 | `alipay.*` | -| MySQL | 数据持久化 | `spring.datasource.*` | -| Redis | Token缓存/会话 | `spring.data.redis.*` | diff --git a/docs/references/divination-agent-api-reference.md b/docs/references/divination-agent-api-reference.md deleted file mode 100644 index 1daea3c..0000000 --- a/docs/references/divination-agent-api-reference.md +++ /dev/null @@ -1,202 +0,0 @@ -# 算卦 Agent API Reference - -## 1. API Endpoint - -- **URL**: `POST https://meeyao.com.cn/api/deepseek/chat` -- **认证**: 需要通过 `AuthInterceptor` 注入用户 token - ---- - -## 2. 请求结构 - -### 2.1 DeepSeekRequest (请求体) - -```kotlin -data class DeepSeekRequest( - val model: String = "deepseek-chat", - val messages: List, - val temperature: Double = 0.7, - val max_tokens: Int = 2048, - val stream: Boolean = false -) -``` - -### 2.2 DeepSeekMessage - -```kotlin -data class DeepSeekMessage( - val role: String, // "system" 或 "user" - val content: String // 系统提示词或用户提示词(含卦象JSON) -) -``` - -### 2.3 DivinationInfo (卦象信息 JSON) - -```kotlin -data class DivinationInfo( - // 用户信息 - val question: String, // 用户问题 - val questionType: String, // 问题类型 (如"事业"、"感情"、"健康") - - // 起卦时间信息 - val divinationTime: String, // 起卦时间 "2024年06月01日 12:00" - val yearGanZhi: String, // 年干支 "甲子" - val monthGanZhi: String, // 月干支 - val dayGanZhi: String, // 日干支 - val timeGanZhi: String, // 时干支 - - // 干支空亡信息 - val yearKongWang: String, // 年空亡 "戌亥" - val monthKongWang: String, // 月空亡 - val dayKongWang: String, // 日空亡 - val timeKongWang: String, // 时空亡 - - // 月建日辰信息 - val yueJian: String, // 月建 "寅木" - val riChen: String, // 日辰 "午火" - val yuePo: String, // 月破 - val riChong: String, // 日冲 - - // 五行旺衰 - val wuXingStatuses: Map, // 五行旺相休囚死状态 - - // 本卦信息 - val guaName: String, // 卦名 "坤为地" - val upperName: String, // 上卦名称 - val lowerName: String, // 下卦名称 - val worldPosition: Int, // 世爻位置 (1-6) - val responsePosition: Int, // 应爻位置 (1-6) - - // 六爻信息 - val yaoInfoList: List, - - // 变卦信息 - val hasChangingYao: Boolean, // 是否有动爻 - val targetGuaName: String, // 变卦名称 - val targetYaoInfoList: List -) -``` - -### 2.4 YaoDetailInfo (爻详细信息) - -```kotlin -data class YaoDetailInfo( - val position: Int, // 爻位置 (1-6: 初爻到上爻) - val spiritName: String, // 神煞 (龙/雀/勾/蛇/虎/玄) - val relationName: String, // 六亲 (兄弟/父母/官鬼/妻财/子孙) - val tiganName: String, // 地支 (子/丑/寅...) - val elementName: String, // 五行 (金/木/水/火/土) - val isYang: Boolean, // 阴阳属性 - val isChanging: Boolean, // 是否为动爻 - val specialMark: String // 特殊标记 (世/应/"") -) -``` - ---- - -## 3. 响应结构 - -### 3.1 DeepSeekResponse - -```kotlin -data class DeepSeekResponse( - val id: String, - val choices: List -) - -data class DeepSeekChoice( - val message: DeepSeekMessage?, // 包含 AI 回复内容 - val finish_reason: String? -) - -data class DeepSeekMessage( - val role: String, - val content: String // AI 返回的解卦结果 -) -``` - ---- - -## 4. 系统提示词 (System Prompt) - -``` -## 角色 -你是一个六爻解卦专家,熟悉六爻解卦步骤以及给出对应的解卦结果... - -## 输出格式要求 -- 单独在最开头输出一句话概括卦象的吉凶 -- 输出顺序:解卦结论、卦象重点、卦象建议、关键词 -- 格式:解卦结论:1、… 2、…;卦象重点:1、… 2、…;卦象建议:1、… 2、…;关键词:… -- 关键词:三个四字成语 -``` - -### 4.1 吉凶等级 - -| 等级 | 描述 | -|------|------| -| 上上签 | 卦象结果较好,完成某事容易或最终结果好 | -| 中上签 | 卦象结果一般,需很努力才能完成或效果一般 | -| 中下签 | 卦象结果较差,即使很努力也无法完成或结果不好 | - ---- - -## 5. 字段说明 - -### 5.1 字段名与含义对照表 - -| 字段名 | 含义 | 示例 | -|--------|------|------| -| `divinationTime` | 起卦时间 | "2024年06月01日 12:00" | -| `yearGanZhi` | 年柱天干地支 | "甲子" | -| `monthGanZhi` | 月柱天干地支 | "丙寅" | -| `dayGanZhi` | 日柱天干地支 | "戊午" | -| `timeGanZhi` | 时柱天干地支 | "庚子" | -| `yearKongWang` | 年柱空亡地支 | "戌亥" | -| `yueJian` | 月建 | "寅木" | -| `riChen` | 日辰 | "午火" | -| `yuePo` | 月破 | "申金" | -| `riChong` | 日冲 | "子水" | -| `guaName` | 本卦卦名 | "坤为地" | -| `upperName` | 上卦名称 | | -| `lowerName` | 下卦名称 | | -| `worldPosition` | 世爻位置 | 1-6 | -| `responsePosition` | 应爻位置 | 1-6 | -| `hasChangingYao` | 是否有动爻 | true/false | -| `targetGuaName` | 变卦卦名 | | - -### 5.2 六神 (spiritName) - -| 神煞 | 含义 | -|------|------| -| 龙 | 青龙 | -| 雀 | 朱雀 | -| 勾 | 勾陈 | -| 蛇 | 螣蛇 | -| 虎 | 白虎 | -| 玄 | 玄武 | - -### 5.3 六亲 (relationName) - -| 六亲 | 含义 | -|------|------| -| 兄弟 | | -| 父母 | | -| 官鬼 | | -| 妻财 | | -| 子孙 | | - -### 5.4 地支 (tiganName) - -子、丑、寅、卯、辰、巳、午、未、申、酉、戌、亥 - -### 5.5 五行 (elementName) - -金、木、水、火、土 - ---- - -## 6. Source - -- Android App: `old/app/src/main/java/com/example/eryaoapp/api/DivinationRepository.kt` -- Request Models: `old/app/src/main/java/com/example/eryaoapp/api/model/DivinationRequest.kt` -- API Service: `old/app/src/main/java/com/example/eryaoapp/api/DeepSeekApiService.kt` diff --git a/docs/references/old-database-schema.md b/docs/references/old-database-schema.md deleted file mode 100644 index 7da476c..0000000 --- a/docs/references/old-database-schema.md +++ /dev/null @@ -1,350 +0,0 @@ -# Old 项目数据库表结构参考 - -本文档记录 `old` 文件夹中历史项目的数据库表结构定义。 - ---- - -## 一、login-service (后端服务) - -### 1. users - 用户表 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGINT | PRIMARY KEY, AUTO_INCREMENT | 主键ID | -| phone_number | VARCHAR(20) | UNIQUE, NOT NULL | 手机号 | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | 更新时间 | - -**索引:** -- `idx_phone_number` ON `phone_number` - ---- - -### 2. verification_codes - 验证码表 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGINT | PRIMARY KEY, AUTO_INCREMENT | 主键ID | -| phone_number | VARCHAR(20) | NOT NULL | 手机号 | -| code | VARCHAR(6) | NOT NULL | 验证码 | -| expiration_time | TIMESTAMP | NOT NULL | 过期时间 | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | - -**索引:** -- `idx_vc_phone_number` ON `phone_number` -- `idx_vc_expiration` ON `expiration_time` - ---- - -### 3. user_profile - 用户资料表 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGINT | PRIMARY KEY, AUTO_INCREMENT | 主键ID | -| phone_number | VARCHAR(20) | UNIQUE, NOT NULL | 手机号 | -| nickname | VARCHAR(50) | | 昵称 | -| avatar_url | VARCHAR(500) | | 头像URL | -| signature | VARCHAR(200) | | 个性签名 | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | 更新时间 | - ---- - -### 4. user_tokens - 用户令牌表 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGINT | PRIMARY KEY, AUTO_INCREMENT | 主键ID | -| user_id | BIGINT | NOT NULL | 用户ID | -| token | VARCHAR(255) | NOT NULL | 访问令牌 | -| refresh_token | VARCHAR(255) | | 刷新令牌 | -| expires_at | TIMESTAMP | | 过期时间 | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | - ---- - -### 5. user_feedback - 用户反馈表 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGINT | PRIMARY KEY, AUTO_INCREMENT | 主键ID | -| user_id | BIGINT | NOT NULL | 用户ID | -| content | TEXT | NOT NULL | 反馈内容 | -| contact | VARCHAR(100) | | 联系方式 | -| status | VARCHAR(20) | DEFAULT 'PENDING' | 处理状态 | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | 更新时间 | - ---- - -### 6. user_coin - 用户金币表 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGINT | PRIMARY KEY, AUTO_INCREMENT | 主键ID | -| user_id | BIGINT | UNIQUE, NOT NULL | 用户ID | -| coin_count | BIGINT | DEFAULT 0 | 金币数量 | -| total_charged | BIGINT | DEFAULT 0 | 累计充值金币 | -| total_consumed | BIGINT | DEFAULT 0 | 累计消费金币 | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | 更新时间 | - ---- - -### 7. notification - 通知表 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGINT | PRIMARY KEY, AUTO_INCREMENT | 主键ID | -| user_id | BIGINT | NOT NULL | 用户ID | -| title | VARCHAR(100) | NOT NULL | 通知标题 | -| content | TEXT | | 通知内容 | -| type | VARCHAR(20) | | 通知类型 | -| is_read | BOOLEAN | DEFAULT FALSE | 是否已读 | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | - ---- - -### 8. payment_record - 支付记录表 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGINT | PRIMARY KEY, AUTO_INCREMENT | 主键ID | -| user_id | BIGINT | NOT NULL | 用户ID | -| order_id | VARCHAR(64) | NOT NULL | 订单ID | -| amount | DECIMAL(10,2) | NOT NULL | 支付金额 | -| coin_amount | BIGINT | NOT NULL | 购买金币数量 | -| payment_method | VARCHAR(20) | | 支付方式 | -| status | VARCHAR(20) | NOT NULL | 支付状态 | -| transaction_id | VARCHAR(100) | | 第三方交易号 | -| paid_at | TIMESTAMP | | 支付时间 | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | - ---- - -### 9. payment_order - 支付订单表 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGINT | PRIMARY KEY, AUTO_INCREMENT | 主键ID | -| order_no | VARCHAR(64) | UNIQUE, NOT NULL | 订单号 | -| user_id | BIGINT | NOT NULL | 用户ID | -| product_id | VARCHAR(50) | NOT NULL | 商品ID | -| product_name | VARCHAR(100) | NOT NULL | 商品名称 | -| amount | DECIMAL(10,2) | NOT NULL | 订单金额 | -| status | VARCHAR(20) | NOT NULL | 订单状态 | -| pay_url | TEXT | | 支付链接 | -| expire_time | TIMESTAMP | | 过期时间 | -| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 | -| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | 更新时间 | - ---- - -### 10. sensitive_word_violations - 敏感词违规记录表 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGINT | PRIMARY KEY, AUTO_INCREMENT | 主键ID | -| user_id | BIGINT | NOT NULL, FK | 用户ID | -| content_type | VARCHAR(20) | NOT NULL | 内容类型:NICKNAME, SIGNATURE | -| violation_type | VARCHAR(30) | NOT NULL | 违规类型:POLITICAL, ILLEGAL, VULGAR, ADVERTISING, PERSONAL_ATTACK | -| detection_service | VARCHAR(20) | DEFAULT 'LOCAL' | 检测服务类型:LOCAL, ALIYUN | -| risk_level | VARCHAR(50) | | 阿里云风险等级 | -| confidence | DOUBLE | | 阿里云置信度(0-1) | -| original_content | TEXT | NOT NULL | 原始内容 | -| matched_words | TEXT | NOT NULL | 匹配到的敏感词(JSON) | -| aliyun_response | TEXT | | 阿里云完整响应 | -| client_ip | VARCHAR(45) | | 客户端IP | -| user_agent | TEXT | | 用户代理 | -| violation_time | DATETIME | NOT NULL | 违规时间 | -| created_at | DATETIME | DEFAULT CURRENT_TIMESTAMP | 创建时间 | - -**索引:** -- `idx_user_id` ON `user_id` -- `idx_content_type` ON `content_type` -- `idx_violation_type` ON `violation_type` -- `idx_violation_time` ON `violation_time` -- `idx_user_violation_time` ON `(user_id, violation_time)` -- `idx_client_ip` ON `client_ip` -- `idx_detection_service` ON `detection_service` -- `idx_risk_level` ON `risk_level` -- `idx_confidence` ON `confidence` - -**外键:** -- `user_id` REFERENCES `user_profile(id)` ON DELETE CASCADE - ---- - -### 11. user_divination_records - 用户解卦记录表 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGINT | PRIMARY KEY, AUTO_INCREMENT | 主键ID | -| user_id | BIGINT | NOT NULL | 用户ID | -| trace_id | VARCHAR(64) | NOT NULL | 请求追踪ID | -| question | TEXT | NOT NULL | 用户问题 | -| question_type | VARCHAR(50) | NOT NULL | 问题类型 | -| divination_data | LONGTEXT | NOT NULL | 卦象详情JSON | -| deepseek_request | LONGTEXT | NOT NULL | 发送给DeepSeek的请求JSON | -| deepseek_response | LONGTEXT | | DeepSeek响应JSON | -| interpretation_result | LONGTEXT | | 解卦结果文本 | -| api_success | BOOLEAN | NOT NULL, DEFAULT FALSE | API调用是否成功 | -| error_message | TEXT | | 错误信息 | -| api_duration_ms | BIGINT | | API调用耗时(毫秒) | -| phone_number | VARCHAR(20) | | 用户手机号(冗余) | -| created_at | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 | - -**索引:** -- `idx_user_id` ON `user_id` -- `idx_trace_id` ON `trace_id` -- `idx_phone_number` ON `phone_number` -- `idx_created_at` ON `created_at` -- `idx_api_success` ON `api_success` -- `idx_question_type` ON `question_type` -- `idx_user_created` ON `(user_id, created_at)` - ---- - -### 12. user_divination_history - 用户卦象历史同步表 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGINT | PRIMARY KEY, AUTO_INCREMENT | 主键ID | -| user_id | BIGINT | NOT NULL, FK | 用户ID | -| phone_number | VARCHAR(20) | NOT NULL | 用户手机号 | -| local_record_id | BIGINT | | 本地记录ID | -| json_data | LONGTEXT | NOT NULL | 卦象详情JSON | -| ai_result | LONGTEXT | NOT NULL | AI解卦结果 | -| question_type | VARCHAR(50) | NOT NULL | 问题类型 | -| question | TEXT | NOT NULL | 用户问题 | -| timestamp | BIGINT | NOT NULL | 创建时间戳(毫秒) | -| is_active | BOOLEAN | NOT NULL, DEFAULT TRUE | 是否有效 | -| sync_time | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 同步时间 | -| updated_at | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | 更新时间 | - -**索引:** -- `idx_user_phone` ON `(user_id, phone_number)` -- `idx_phone_active` ON `(phone_number, is_active)` -- `idx_user_active_time` ON `(user_id, is_active, timestamp)` -- `idx_local_record` ON `local_record_id` -- `idx_sync_time` ON `sync_time` -- `idx_question_type` ON `question_type` - -**外键:** -- `user_id` REFERENCES `user_profile(id)` - ---- - -### 13. network_access_logs - 网络访问日志表 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGINT | PRIMARY KEY, AUTO_INCREMENT | 主键ID | -| user_id | BIGINT | NULL | 用户ID | -| phone_number | VARCHAR(20) | NULL | 用户手机号 | -| client_ip | VARCHAR(45) | NOT NULL | 客户端IP | -| client_port | INT | NULL | 客户端端口 | -| server_ip | VARCHAR(45) | NOT NULL | 服务器IP | -| server_port | INT | NOT NULL | 服务器端口 | -| http_method | VARCHAR(10) | NOT NULL | 请求方法 | -| request_path | VARCHAR(500) | NOT NULL | 请求路径 | -| request_url | VARCHAR(1000) | NOT NULL | 完整请求URL | -| user_agent | VARCHAR(1000) | NULL | User-Agent | -| device_info | TEXT | NULL | 设备信息JSON | -| response_status | INT | NULL | HTTP响应状态码 | -| processing_time_ms | BIGINT | NULL | 处理耗时(毫秒) | -| request_size | BIGINT | NULL | 请求体大小(字节) | -| response_size | BIGINT | NULL | 响应体大小(字节) | -| x_forwarded_for | VARCHAR(500) | NULL | X-Forwarded-For | -| x_real_ip | VARCHAR(45) | NULL | X-Real-IP | -| referer | VARCHAR(1000) | NULL | Referer | -| operation_type | VARCHAR(50) | NULL | 操作类型 | -| operation_result | VARCHAR(20) | NULL | 操作结果 | -| error_message | TEXT | NULL | 错误信息 | -| session_id | VARCHAR(100) | NULL | 会话ID | -| access_time | DATETIME | NOT NULL | 访问时间 | -| created_at | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 | - -**索引:** -- `idx_user_id` ON `user_id` -- `idx_phone_number` ON `phone_number` -- `idx_client_ip` ON `client_ip` -- `idx_access_time` ON `access_time` -- `idx_operation_type` ON `operation_type` -- `idx_operation_result` ON `operation_result` -- `idx_client_ip_access_time` ON `(client_ip, access_time)` -- `idx_user_id_access_time` ON `(user_id, access_time)` - ---- - -### 14. app_version - 应用版本管理表 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGINT | PRIMARY KEY, AUTO_INCREMENT | 主键ID | -| version_name | VARCHAR(20) | UNIQUE, NOT NULL | 版本名称(如v1.06) | -| version_code | INT | UNIQUE, NOT NULL | 版本号(如106) | -| min_supported_version | VARCHAR(20) | NOT NULL | 最低支持版本 | -| min_supported_code | INT | NOT NULL | 最低支持版本号 | -| is_force_update | BOOLEAN | NOT NULL, DEFAULT FALSE | 是否强制更新 | -| update_message | TEXT | | 更新提示信息 | -| download_url | VARCHAR(500) | | 下载链接 | -| is_active | BOOLEAN | NOT NULL, DEFAULT TRUE | 是否启用 | -| created_at | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP | 创建时间 | -| updated_at | DATETIME | NOT NULL, DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP | 更新时间 | - -**索引:** -- `uk_version_name` UNIQUE ON `version_name` -- `uk_version_code` UNIQUE ON `version_code` -- `idx_is_active` ON `is_active` -- `idx_created_at` ON `created_at` - ---- - -## 二、app (Android 客户端本地 Room 数据库) - -### 1. divination_record - 解卦记录表 - -| 字段 | 类型 | 约束 | 说明 | -|------|------|------|------| -| id | BIGINT | PRIMARY KEY, AUTO_INCREMENT | 主键ID | -| question_type | VARCHAR(50) | NOT NULL | 问题类型 | -| question | TEXT | NOT NULL | 用户问题 | -| hexagram_data | TEXT | NOT NULL | 卦象数据JSON | -| ai_result | TEXT | NOT NULL | AI解卦结果 | -| timestamp | BIGINT | NOT NULL | 创建时间戳 | -| is_synced | BOOLEAN | DEFAULT FALSE | 是否已同步到云端 | -| is_deleted | BOOLEAN | DEFAULT FALSE | 是否已删除 | - ---- - -## 三、数据库初始化文件位置 - -| 文件 | 说明 | -|------|------| -| `login-service/src/main/resources/db/init.sql` | 初始化表结构 | -| `login-service/src/main/resources/db/migration.sql` | 迁移脚本 | -| `login-service/src/main/resources/db/migration/V1_4__Create_sensitive_word_violations_table.sql` | 敏感词表创建 | -| `login-service/src/main/resources/db/migration/V1_5__Enhance_sensitive_word_violations_table.sql` | 敏感词表增强 | - ---- - -## 四、Entity 类位置 - -| Entity 类 | 表名 | 位置 | -|-----------|------|------| -| UsersEntity | users | `login-service/src/main/kotlin/com/eryao/login/entity/UsersEntity.kt` | -| VerificationCode | verification_codes | `login-service/src/main/kotlin/com/eryao/login/entity/VerificationCode.kt` | -| User | user_profile | `login-service/src/main/kotlin/com/eryao/login/entity/User.kt` | -| UserToken | user_tokens | `login-service/src/main/kotlin/com/eryao/login/entity/UserToken.kt` | -| UserFeedback | user_feedback | `login-service/src/main/kotlin/com/eryao/login/entity/UserFeedback.kt` | -| UserCoin | user_coin | `login-service/src/main/kotlin/com/eryao/login/entity/UserCoin.kt` | -| Notification | notification | `login-service/src/main/kotlin/com/eryao/login/entity/Notification.kt` | -| PaymentRecord | payment_record | `login-service/src/main/kotlin/com/eryao/login/entity/PaymentRecord.kt` | -| PaymentOrder | payment_order | `login-service/src/main/kotlin/com/eryao/login/entity/PaymentOrder.kt` | -| SensitiveWordViolation | sensitive_word_violations | `login-service/src/main/kotlin/com/eryao/login/entity/SensitiveWordViolation.kt` | -| DivinationRecord | user_divination_records | `login-service/src/main/kotlin/com/eryao/login/entity/DivinationRecord.kt` | -| DivinationHistory | user_divination_history | `login-service/src/main/kotlin/com/eryao/login/entity/DivinationRecord.kt` | -| NetworkAccessLog | network_access_logs | `login-service/src/main/kotlin/com/eryao/login/entity/NetworkAccessLog.kt` | -| AppVersion | app_version | `login-service/src/main/kotlin/com/eryao/login/entity/AppVersion.kt` | -| DivinationRecord (Room) | divination_record | `app/src/main/java/com/example/eryaoapp/database/DivinationRecord.kt` | diff --git a/infra/docker/supabase/docker-compose.yml b/infra/docker/supabase/docker-compose.yml index 1901bfe..15307d1 100644 --- a/infra/docker/supabase/docker-compose.yml +++ b/infra/docker/supabase/docker-compose.yml @@ -121,7 +121,7 @@ services: REGION: local ENABLE_IMAGE_TRANSFORMATION: "false" volumes: - - ./volumes/storage:/var/lib/storage + - storage-data:/var/lib/storage meta: container_name: supabase-meta @@ -214,3 +214,4 @@ services: volumes: db-config: + storage-data: diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..78e177d --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "skills": { + "ios-device-automation": { + "source": "web-infra-dev/midscene-skills", + "sourceType": "github", + "computedHash": "76af67d516475bf52d4bfe881a9efceb940131b7f1ee4626e06a8e88f1384c13" + }, + "ui-ux-pro-max": { + "source": "nextlevelbuilder/ui-ux-pro-max-skill", + "sourceType": "github", + "computedHash": "0a413bf988d06481f69bb81df2070741c3ba12dd9f1be2706d57f259c905992d" + } + } +}