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 0000000..0fd1494 Binary files /dev/null and b/QQ20260406-003407.png differ diff --git a/apps/.metadata b/apps/.metadata index e8cf1e4..ad0e577 100644 --- a/apps/.metadata +++ b/apps/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "3b62efc2a3da49882f43c372e0bc53daef7295a6" + revision: "db50e20168db8fee486b9abf32fc912de3bc5b6a" channel: "stable" project_type: app @@ -13,14 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - - platform: android - create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a - platform: ios - create_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 - base_revision: 3b62efc2a3da49882f43c372e0bc53daef7295a6 + create_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a + base_revision: db50e20168db8fee486b9abf32fc912de3bc5b6a # User provided section diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 3efe608..fb1b795 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -48,7 +48,8 @@ This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable. ## Divination Terminology (Must) - Divination domain terminology must use fixed Chinese terms in code contracts, protocol fields, and UI semantic labels. -- Do not localize or translate canonical terms such as: 六爻、爻、动爻、静爻、六亲、六神、世爻、应爻、伏神、月建、日辰、月破、日冲、空亡、五行旺衰、上上签、中上签、中下签。 +- Do not localize or translate canonical terms such as: 六爻、爻、动爻、静爻、六亲、六神、世爻、应爻、伏神、月建、日辰、月破、日冲、空亡、五行旺衰。 +- Signature level labels (`上上签/中上签/中下签`) may be localized for UI display only, while protocol/storage values remain canonical Chinese. - l10n can translate explanatory copy, but must not alter canonical divination terminology semantics. ## Reuse & Composition (Must) diff --git a/apps/ios/Flutter/AppFrameworkInfo.plist b/apps/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf7..391a902 100644 --- a/apps/ios/Flutter/AppFrameworkInfo.plist +++ b/apps/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? 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" + } + } +}