diff --git a/apps/lib/app/app.dart b/apps/lib/app/app.dart index e53e215..2b90cf3 100644 --- a/apps/lib/app/app.dart +++ b/apps/lib/app/app.dart @@ -305,6 +305,21 @@ class _EryaoAppState extends State { return saved; } + Future _deleteAccount() async { + await _profileApi.deleteAccount(); + if (!mounted) { + return; + } + setState(() { + _profileSettings = ProfileSettingsV1.defaultsForLocale(_locale); + _historyRecords = const []; + _creditsBalance = 0; + _loadedProfileUserEmail = null; + _loadedHistoryUserEmail = null; + _loadedCreditsUserEmail = null; + }); + } + Future _saveProfileSettings(ProfileSettingsV1 next) async { try { final oldLanguage = _profileSettings.preferences.interfaceLanguage; @@ -415,6 +430,7 @@ class _EryaoAppState extends State { onDivinationCompleted: _handleDivinationCompleted, onDeleteHistorySession: _handleHistorySessionDeleted, onLogout: _authBloc.logout, + onDeleteAccount: _deleteAccount, ); } diff --git a/apps/lib/core/network/api_problem_mapper.dart b/apps/lib/core/network/api_problem_mapper.dart index 2297ae5..6224c0e 100644 --- a/apps/lib/core/network/api_problem_mapper.dart +++ b/apps/lib/core/network/api_problem_mapper.dart @@ -29,6 +29,8 @@ String mapApiProblemToMessage(ApiProblem problem, AppLocalizations l10n) { return l10n.errorAudioEmpty; case 'AGENT_ASR_UNAVAILABLE': return l10n.errorAsrUnavailable; + case 'PROFILE_DELETE_FAILED': + return l10n.errorProfileDeleteFailed; default: break; } diff --git a/apps/lib/data/network/api_client.dart b/apps/lib/data/network/api_client.dart index e3b8e6b..c90f7f4 100644 --- a/apps/lib/data/network/api_client.dart +++ b/apps/lib/data/network/api_client.dart @@ -31,7 +31,13 @@ class ApiClient { final authHeader = error.requestOptions.headers['Authorization'] as String?; final hasAuthHeader = authHeader != null && authHeader.isNotEmpty; - if (status == 401 && hasAuthHeader && onUnauthorized != null) { + final isLogoutEndpoint = + error.requestOptions.method.toUpperCase() == 'DELETE' && + error.requestOptions.path == '/api/v1/auth/sessions'; + if (status == 401 && + hasAuthHeader && + onUnauthorized != null && + !isLogoutEndpoint) { await onUnauthorized(); } handler.next(error); diff --git a/apps/lib/features/auth/data/apis/auth_api.dart b/apps/lib/features/auth/data/apis/auth_api.dart index 769a86d..4647c2b 100644 --- a/apps/lib/features/auth/data/apis/auth_api.dart +++ b/apps/lib/features/auth/data/apis/auth_api.dart @@ -1,3 +1,6 @@ +import 'package:dio/dio.dart'; + +import '../../../../core/network/api_problem.dart'; import '../../../../data/network/api_client.dart'; import '../models/session_response.dart'; @@ -25,9 +28,32 @@ class AuthApi { } Future deleteSession({required String refreshToken}) async { - await _apiClient.deleteNoContent( + final response = await _apiClient.rawDio.delete>( '/api/v1/auth/sessions', data: {'refresh_token': refreshToken}, + options: Options( + validateStatus: (status) => status != null && status < 500, + ), + ); + final status = response.statusCode ?? 500; + if (status == 204 || status == 401) { + return; + } + + final data = response.data; + if (data is Map) { + throw ApiProblem( + status: status, + title: (data['title'] as String?) ?? 'Request failed', + detail: (data['detail'] as String?) ?? '', + code: data['code'] as String?, + ); + } + + throw ApiProblem( + status: status, + title: 'Request failed', + detail: 'Failed to delete session', ); } diff --git a/apps/lib/features/auth/data/repositories/auth_repository.dart b/apps/lib/features/auth/data/repositories/auth_repository.dart index e3bd39a..5e4141b 100644 --- a/apps/lib/features/auth/data/repositories/auth_repository.dart +++ b/apps/lib/features/auth/data/repositories/auth_repository.dart @@ -1,4 +1,5 @@ import '../../../../core/auth/session_store.dart'; +import '../../../../core/network/api_problem.dart'; import '../apis/auth_api.dart'; import '../models/auth_user.dart'; @@ -70,7 +71,14 @@ class AuthRepositoryImpl implements AuthRepository { try { final refreshToken = await _sessionStore.getRefreshToken(); if (refreshToken != null && refreshToken.isNotEmpty) { - await _authApi.deleteSession(refreshToken: refreshToken); + try { + await _authApi.deleteSession(refreshToken: refreshToken); + } on ApiProblem catch (problem) { + if (problem.status != 401 || + problem.code != 'AUTH_REFRESH_TOKEN_INVALID') { + rethrow; + } + } } } finally { await clearLocalSession(); diff --git a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart index 0440077..86a0715 100644 --- a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; import '../../../../core/logging/logger.dart'; @@ -56,18 +58,19 @@ class AuthBloc extends ChangeNotifier { } Future logout() async { - try { - await _repository.logout(); - } catch (error, stackTrace) { - _logger.error( - message: 'User logout failed: ${error.runtimeType}', - error: error.runtimeType.toString(), - stackTrace: stackTrace, - ); - } _logger.info(message: 'User logged out'); _state = const AuthState(status: AuthStatus.unauthenticated); notifyListeners(); + + unawaited( + _repository.logout().catchError((Object error, StackTrace stackTrace) { + _logger.error( + message: 'User logout failed: ${error.runtimeType}', + error: error.runtimeType.toString(), + stackTrace: stackTrace, + ); + }), + ); } Future handleUnauthorized401() async { diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart index a69b08d..410dc4d 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -35,6 +35,7 @@ class HomeScreen extends StatefulWidget { required this.onDivinationCompleted, required this.onDeleteHistorySession, required this.onLogout, + required this.onDeleteAccount, }); final String account; @@ -54,6 +55,7 @@ class HomeScreen extends StatefulWidget { onDivinationCompleted; final Future Function(String threadId) onDeleteHistorySession; final Future Function() onLogout; + final Future Function() onDeleteAccount; @override State createState() => _HomeScreenState(); @@ -116,6 +118,7 @@ class _HomeScreenState extends State { onSaveProfile: widget.onSaveProfile, onUploadAvatar: widget.onUploadAvatar, onLogout: widget.onLogout, + onDeleteAccount: widget.onDeleteAccount, ), ], ), @@ -505,6 +508,7 @@ class _ProfileTab extends StatelessWidget { required this.onSaveProfile, required this.onUploadAvatar, required this.onLogout, + required this.onDeleteAccount, }); final String account; @@ -516,6 +520,7 @@ class _ProfileTab extends StatelessWidget { onSaveProfile; final Future Function(String filePath) onUploadAvatar; final Future Function() onLogout; + final Future Function() onDeleteAccount; @override Widget build(BuildContext context) { @@ -528,6 +533,7 @@ class _ProfileTab extends StatelessWidget { onSaveProfile: onSaveProfile, onUploadAvatar: onUploadAvatar, onLogout: onLogout, + onDeleteAccount: onDeleteAccount, ); } } diff --git a/apps/lib/features/settings/data/apis/profile_api.dart b/apps/lib/features/settings/data/apis/profile_api.dart index 0d2de44..6a8a9c6 100644 --- a/apps/lib/features/settings/data/apis/profile_api.dart +++ b/apps/lib/features/settings/data/apis/profile_api.dart @@ -87,6 +87,10 @@ class ProfileApi { return _toSettings(data); } + Future deleteAccount() async { + await _apiClient.deleteNoContent('/api/v1/users/me'); + } + ProfileSettingsV1 _toSettings(Map json) { final settingsRaw = json['settings']; final preferencesRaw = settingsRaw is Map diff --git a/apps/lib/features/settings/presentation/screens/account_delete_screen.dart b/apps/lib/features/settings/presentation/screens/account_delete_screen.dart new file mode 100644 index 0000000..b822412 --- /dev/null +++ b/apps/lib/features/settings/presentation/screens/account_delete_screen.dart @@ -0,0 +1,268 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../../core/logging/logger.dart'; +import '../../../../core/network/api_problem.dart'; +import '../../../../core/network/api_problem_mapper.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 '../widgets/settings_section_widgets.dart'; + +class AccountDeleteScreen extends StatefulWidget { + const AccountDeleteScreen({super.key, required this.onDeleteAccount}); + + final Future Function() onDeleteAccount; + + @override + State createState() => _AccountDeleteScreenState(); +} + +class _AccountDeleteScreenState extends State { + final Logger _logger = getLogger('features.settings.account_delete'); + bool _isDeleting = false; + + @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.settingsAccountAndDataTitle), + centerTitle: true, + backgroundColor: colors.surfaceContainerLow, + surfaceTintColor: colors.surfaceContainerLow, + ), + body: ListView( + padding: const EdgeInsets.all(AppSpacing.lg), + children: [ + SettingsGroupCard( + children: [ + SettingsMenuTile( + icon: Icons.delete_outline_rounded, + title: l10n.settingsDeleteAccountTitle, + tint: colors.error, + background: colors.surfaceContainerHighest, + titleColor: colors.error, + showDivider: false, + onTap: _isDeleting ? () {} : _confirmDelete, + ), + ], + ), + ], + ), + ); + } + + Future _confirmDelete() async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return const _DeleteConfirmDialog(); + }, + ); + if (confirmed != true || !mounted) { + return; + } + await _deleteAccount(); + } + + Future _deleteAccount() async { + if (_isDeleting) { + return; + } + setState(() { + _isDeleting = true; + }); + try { + await widget.onDeleteAccount(); + if (!mounted) { + return; + } + Navigator.of(context).pop(true); + } catch (error, stackTrace) { + _logger.error( + message: 'Delete account request failed', + error: error, + stackTrace: stackTrace, + ); + if (!mounted) { + return; + } + final l10n = AppLocalizations.of(context)!; + final message = error is ApiProblem + ? mapApiProblemToMessage(error, l10n) + : l10n.errorRequestGeneric; + Toast.show(context, message, type: ToastType.error); + setState(() { + _isDeleting = false; + }); + } + } +} + +class _DeleteConfirmDialog extends StatefulWidget { + const _DeleteConfirmDialog(); + + @override + State<_DeleteConfirmDialog> createState() => _DeleteConfirmDialogState(); +} + +class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> { + static const int _coolDownSeconds = 5; + int _secondsLeft = _coolDownSeconds; + Timer? _timer; + + @override + void initState() { + super.initState(); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + if (_secondsLeft <= 1) { + setState(() { + _secondsLeft = 0; + }); + timer.cancel(); + return; + } + setState(() { + _secondsLeft -= 1; + }); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(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( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(AppRadius.md), + ), + alignment: Alignment.center, + child: Icon( + Icons.warning_rounded, + color: colors.error, + size: 22, + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text( + l10n.settingsDeleteAccountDialogTitle, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + Text( + l10n.settingsDeleteAccountWarningBody, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + height: 1.45, + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + _secondsLeft > 0 + ? l10n.settingsDeleteAccountWaitAction(_secondsLeft) + : l10n.settingsDeleteAccountDialogBody, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.error, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.lg), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.of(context).pop(false), + 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(l10n.settingsCancel), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: FilledButton( + onPressed: _secondsLeft <= 0 + ? () => Navigator.of(context).pop(true) + : null, + style: FilledButton.styleFrom( + backgroundColor: colors.error, + foregroundColor: colors.onError, + disabledBackgroundColor: colors.error.withValues( + alpha: 0.4, + ), + disabledForegroundColor: colors.onError.withValues( + alpha: 0.8, + ), + minimumSize: const Size.fromHeight(44), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), + child: Text(l10n.settingsDeleteAccountAction), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/apps/lib/features/settings/presentation/screens/settings_screen.dart b/apps/lib/features/settings/presentation/screens/settings_screen.dart index 84ab40e..4d8b0d3 100644 --- a/apps/lib/features/settings/presentation/screens/settings_screen.dart +++ b/apps/lib/features/settings/presentation/screens/settings_screen.dart @@ -5,6 +5,7 @@ import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/widgets/app_modal_dialog.dart'; import '../../../../shared/widgets/gua_icon.dart'; import '../../data/models/profile_settings.dart'; +import 'account_delete_screen.dart'; import '../widgets/settings_section_widgets.dart'; import 'coin_center_screen.dart'; import 'general_settings_screen.dart'; @@ -22,6 +23,7 @@ class SettingsScreen extends StatefulWidget { required this.onSettingsChanged, required this.onUploadAvatar, required this.onLogout, + required this.onDeleteAccount, required this.onSaveProfile, }); @@ -32,6 +34,7 @@ class SettingsScreen extends StatefulWidget { final Future Function(ProfileSettingsV1 settings) onSettingsChanged; final Future Function(String filePath) onUploadAvatar; final Future Function() onLogout; + final Future Function() onDeleteAccount; final Future Function(ProfileSettingsV1 updated) onSaveProfile; @@ -119,6 +122,18 @@ class _SettingsScreenState extends State { ), ], ), + SettingsGroupCard( + children: [ + SettingsMenuTile( + icon: Icons.person_rounded, + title: l10n.settingsAccountAndDataTitle, + tint: colors.primary, + background: colors.surfaceContainerHighest, + showDivider: false, + onTap: _openAccountDelete, + ), + ], + ), const SizedBox(height: AppSpacing.xl), FilledButton( onPressed: _confirmLogout, @@ -194,6 +209,23 @@ class _SettingsScreenState extends State { ); } + Future _openAccountDelete() async { + final deleted = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + AccountDeleteScreen(onDeleteAccount: widget.onDeleteAccount), + ), + ); + if (deleted != true) { + return; + } + await widget.onLogout(); + if (!mounted) { + return; + } + Navigator.of(context).popUntil((route) => route.isFirst); + } + Future _confirmLogout() async { final l10n = AppLocalizations.of(context)!; final confirmed = await showDialog( 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 37fb261..58f9b0c 100644 --- a/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart +++ b/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart @@ -62,6 +62,8 @@ class SettingsMenuTile extends StatelessWidget { this.showChevron = true, this.trailing, this.subtitle, + this.titleColor, + this.subtitleColor, }); final IconData icon; @@ -73,6 +75,8 @@ class SettingsMenuTile extends StatelessWidget { final bool showDivider; final bool showChevron; final Widget? trailing; + final Color? titleColor; + final Color? subtitleColor; @override Widget build(BuildContext context) { @@ -94,12 +98,27 @@ class SettingsMenuTile extends StatelessWidget { ), child: Icon(icon, color: tint), ), - title: Text(title), + title: Text( + title, + style: titleColor == null + ? null + : Theme.of(context).textTheme.bodyLarge?.copyWith( + color: titleColor, + fontWeight: FontWeight.w600, + ), + ), subtitle: subtitle == null ? null : Padding( padding: const EdgeInsets.only(top: AppSpacing.xs), - child: Text(subtitle!), + child: Text( + subtitle!, + style: subtitleColor == null + ? null + : Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: subtitleColor), + ), ), trailing: trailing ?? @@ -162,7 +181,7 @@ class SettingsSwitchTile extends StatelessWidget { trailing: Switch( value: value, onChanged: onChanged, - activeColor: colors.primary, + activeThumbColor: colors.primary, ), ), if (showDivider) diff --git a/apps/lib/l10n/app_en.arb b/apps/lib/l10n/app_en.arb index 9398c11..b0ccb7d 100644 --- a/apps/lib/l10n/app_en.arb +++ b/apps/lib/l10n/app_en.arb @@ -82,6 +82,7 @@ "settingsSectionAccount": "Account", "settingsSectionPrivacy": "Privacy", "settingsSectionNotification": "Notification Settings", + "settingsAccountAndDataTitle": "Account Data", "settingsInterfaceLanguage": "Interface Language", "settingsAiLanguage": "AI Response Language", "settingsNotificationAllow": "Allow Notifications", @@ -141,6 +142,25 @@ "settingsLogoutSubtitle": "Sign out from the current account", "settingsLogoutDialogTitle": "Confirm logout?", "settingsLogoutDialogBody": "You will need to sign in again to continue with this account.", + "settingsDeleteAccountTitle": "Delete Account", + "settingsDeleteAccountSubtitle": "Permanently delete your account and personal data", + "settingsDeleteAccountWarningTitle": "Please confirm before deleting", + "settingsDeleteAccountWarningBody": "After deletion, related data including profile, history, and points will be permanently removed and cannot be restored.", + "settingsDeleteAccountScopeProfile": "Profile and account information will be deleted", + "settingsDeleteAccountScopeHistory": "Divination history records will be deleted", + "settingsDeleteAccountScopePoints": "Points account and ledger records will be deleted", + "settingsDeleteAccountDialogTitle": "Permanently delete this account?", + "settingsDeleteAccountDialogBody": "This action cannot be undone. Deletion will start immediately after confirmation.", + "settingsDeleteAccountAction": "Delete Account", + "settingsDeleteAccountProcessing": "Deleting...", + "settingsDeleteAccountWaitAction": "Wait {seconds}s before confirming deletion", + "@settingsDeleteAccountWaitAction": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, "settingsCancel": "Cancel", "settingsLogoutConfirmHint": "Tap again to confirm logout", "settingsLogoutConfirmAction": "Tap again to logout", @@ -202,6 +222,7 @@ "errorServiceUnavailable": "Service unavailable, please try again later", "errorServerGeneric": "Server error, please try again later", "errorRequestGeneric": "Request failed, please try again", + "errorProfileDeleteFailed": "Failed to delete account, please try again", "errorRunLimitExceeded": "Run limit reached in this session. Please start a new divination.", "errorDivinationPayloadRequired": "Missing divination payload. Please cast again.", "divinationScreenTitle": "Cast Hexagram", diff --git a/apps/lib/l10n/app_localizations.dart b/apps/lib/l10n/app_localizations.dart index 6cf06d3..4cff5af 100644 --- a/apps/lib/l10n/app_localizations.dart +++ b/apps/lib/l10n/app_localizations.dart @@ -506,6 +506,12 @@ abstract class AppLocalizations { /// **'通知设置'** String get settingsSectionNotification; + /// No description provided for @settingsAccountAndDataTitle. + /// + /// In zh, this message translates to: + /// **'账号数据'** + String get settingsAccountAndDataTitle; + /// No description provided for @settingsInterfaceLanguage. /// /// In zh, this message translates to: @@ -728,6 +734,78 @@ abstract class AppLocalizations { /// **'退出后需要重新登录才能继续使用当前账户。'** String get settingsLogoutDialogBody; + /// No description provided for @settingsDeleteAccountTitle. + /// + /// In zh, this message translates to: + /// **'删除账号'** + String get settingsDeleteAccountTitle; + + /// No description provided for @settingsDeleteAccountSubtitle. + /// + /// In zh, this message translates to: + /// **'永久删除账号及相关个人数据'** + String get settingsDeleteAccountSubtitle; + + /// No description provided for @settingsDeleteAccountWarningTitle. + /// + /// In zh, this message translates to: + /// **'删除前请确认'** + String get settingsDeleteAccountWarningTitle; + + /// No description provided for @settingsDeleteAccountWarningBody. + /// + /// In zh, this message translates to: + /// **'删除账号后,个人资料、历史记录、点数信息等相关数据将被永久删除,且不可恢复。'** + String get settingsDeleteAccountWarningBody; + + /// No description provided for @settingsDeleteAccountScopeProfile. + /// + /// In zh, this message translates to: + /// **'个人资料和账号信息会被删除'** + String get settingsDeleteAccountScopeProfile; + + /// No description provided for @settingsDeleteAccountScopeHistory. + /// + /// In zh, this message translates to: + /// **'历史解卦记录会被删除'** + String get settingsDeleteAccountScopeHistory; + + /// No description provided for @settingsDeleteAccountScopePoints. + /// + /// In zh, this message translates to: + /// **'点数账户与流水记录会被删除'** + String get settingsDeleteAccountScopePoints; + + /// No description provided for @settingsDeleteAccountDialogTitle. + /// + /// In zh, this message translates to: + /// **'确认永久删除账号?'** + String get settingsDeleteAccountDialogTitle; + + /// No description provided for @settingsDeleteAccountDialogBody. + /// + /// In zh, this message translates to: + /// **'此操作无法撤销。确认后将立即发起删除。'** + String get settingsDeleteAccountDialogBody; + + /// No description provided for @settingsDeleteAccountAction. + /// + /// In zh, this message translates to: + /// **'确认删除账号'** + String get settingsDeleteAccountAction; + + /// No description provided for @settingsDeleteAccountProcessing. + /// + /// In zh, this message translates to: + /// **'正在删除...'** + String get settingsDeleteAccountProcessing; + + /// No description provided for @settingsDeleteAccountWaitAction. + /// + /// In zh, this message translates to: + /// **'请等待 {seconds} 秒后确认删除'** + String settingsDeleteAccountWaitAction(int seconds); + /// No description provided for @settingsCancel. /// /// In zh, this message translates to: @@ -1010,6 +1088,12 @@ abstract class AppLocalizations { /// **'请求失败,请稍后重试'** String get errorRequestGeneric; + /// No description provided for @errorProfileDeleteFailed. + /// + /// In zh, this message translates to: + /// **'删除账号失败,请稍后重试'** + String get errorProfileDeleteFailed; + /// No description provided for @errorRunLimitExceeded. /// /// 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 3fc9b60..eebed96 100644 --- a/apps/lib/l10n/app_localizations_en.dart +++ b/apps/lib/l10n/app_localizations_en.dart @@ -222,6 +222,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsSectionNotification => 'Notification Settings'; + @override + String get settingsAccountAndDataTitle => 'Account Data'; + @override String get settingsInterfaceLanguage => 'Interface Language'; @@ -349,6 +352,52 @@ class AppLocalizationsEn extends AppLocalizations { String get settingsLogoutDialogBody => 'You will need to sign in again to continue with this account.'; + @override + String get settingsDeleteAccountTitle => 'Delete Account'; + + @override + String get settingsDeleteAccountSubtitle => + 'Permanently delete your account and personal data'; + + @override + String get settingsDeleteAccountWarningTitle => + 'Please confirm before deleting'; + + @override + String get settingsDeleteAccountWarningBody => + 'After deletion, related data including profile, history, and points will be permanently removed and cannot be restored.'; + + @override + String get settingsDeleteAccountScopeProfile => + 'Profile and account information will be deleted'; + + @override + String get settingsDeleteAccountScopeHistory => + 'Divination history records will be deleted'; + + @override + String get settingsDeleteAccountScopePoints => + 'Points account and ledger records will be deleted'; + + @override + String get settingsDeleteAccountDialogTitle => + 'Permanently delete this account?'; + + @override + String get settingsDeleteAccountDialogBody => + 'This action cannot be undone. Deletion will start immediately after confirmation.'; + + @override + String get settingsDeleteAccountAction => 'Delete Account'; + + @override + String get settingsDeleteAccountProcessing => 'Deleting...'; + + @override + String settingsDeleteAccountWaitAction(int seconds) { + return 'Wait ${seconds}s before confirming deletion'; + } + @override String get settingsCancel => 'Cancel'; @@ -501,6 +550,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get errorRequestGeneric => 'Request failed, please try again'; + @override + String get errorProfileDeleteFailed => + 'Failed to delete account, please try again'; + @override String get errorRunLimitExceeded => 'Run limit reached in this session. Please start a new divination.'; diff --git a/apps/lib/l10n/app_localizations_zh.dart b/apps/lib/l10n/app_localizations_zh.dart index 51ab4fd..e60c685 100644 --- a/apps/lib/l10n/app_localizations_zh.dart +++ b/apps/lib/l10n/app_localizations_zh.dart @@ -220,6 +220,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsSectionNotification => '通知设置'; + @override + String get settingsAccountAndDataTitle => '账号数据'; + @override String get settingsInterfaceLanguage => '界面语言'; @@ -343,6 +346,45 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsLogoutDialogBody => '退出后需要重新登录才能继续使用当前账户。'; + @override + String get settingsDeleteAccountTitle => '删除账号'; + + @override + String get settingsDeleteAccountSubtitle => '永久删除账号及相关个人数据'; + + @override + String get settingsDeleteAccountWarningTitle => '删除前请确认'; + + @override + String get settingsDeleteAccountWarningBody => + '删除账号后,个人资料、历史记录、点数信息等相关数据将被永久删除,且不可恢复。'; + + @override + String get settingsDeleteAccountScopeProfile => '个人资料和账号信息会被删除'; + + @override + String get settingsDeleteAccountScopeHistory => '历史解卦记录会被删除'; + + @override + String get settingsDeleteAccountScopePoints => '点数账户与流水记录会被删除'; + + @override + String get settingsDeleteAccountDialogTitle => '确认永久删除账号?'; + + @override + String get settingsDeleteAccountDialogBody => '此操作无法撤销。确认后将立即发起删除。'; + + @override + String get settingsDeleteAccountAction => '确认删除账号'; + + @override + String get settingsDeleteAccountProcessing => '正在删除...'; + + @override + String settingsDeleteAccountWaitAction(int seconds) { + return '请等待 $seconds 秒后确认删除'; + } + @override String get settingsCancel => '取消'; @@ -491,6 +533,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get errorRequestGeneric => '请求失败,请稍后重试'; + @override + String get errorProfileDeleteFailed => '删除账号失败,请稍后重试'; + @override String get errorRunLimitExceeded => '本次会话追问次数已达上限,请新起一卦'; diff --git a/apps/lib/l10n/app_zh.arb b/apps/lib/l10n/app_zh.arb index 7a0347b..06b6728 100644 --- a/apps/lib/l10n/app_zh.arb +++ b/apps/lib/l10n/app_zh.arb @@ -82,6 +82,7 @@ "settingsSectionAccount": "账户操作", "settingsSectionPrivacy": "隐私设置", "settingsSectionNotification": "通知设置", + "settingsAccountAndDataTitle": "账号数据", "settingsInterfaceLanguage": "界面语言", "settingsAiLanguage": "AI回复语言", "settingsNotificationAllow": "允许通知", @@ -141,6 +142,25 @@ "settingsLogoutSubtitle": "退出当前登录账户", "settingsLogoutDialogTitle": "确认退出登录?", "settingsLogoutDialogBody": "退出后需要重新登录才能继续使用当前账户。", + "settingsDeleteAccountTitle": "删除账号", + "settingsDeleteAccountSubtitle": "永久删除账号及相关个人数据", + "settingsDeleteAccountWarningTitle": "删除前请确认", + "settingsDeleteAccountWarningBody": "删除账号后,个人资料、历史记录、点数信息等相关数据将被永久删除,且不可恢复。", + "settingsDeleteAccountScopeProfile": "个人资料和账号信息会被删除", + "settingsDeleteAccountScopeHistory": "历史解卦记录会被删除", + "settingsDeleteAccountScopePoints": "点数账户与流水记录会被删除", + "settingsDeleteAccountDialogTitle": "确认永久删除账号?", + "settingsDeleteAccountDialogBody": "此操作无法撤销。确认后将立即发起删除。", + "settingsDeleteAccountAction": "确认删除账号", + "settingsDeleteAccountProcessing": "正在删除...", + "settingsDeleteAccountWaitAction": "请等待 {seconds} 秒后确认删除", + "@settingsDeleteAccountWaitAction": { + "placeholders": { + "seconds": { + "type": "int" + } + } + }, "settingsCancel": "取消", "settingsLogoutConfirmHint": "再次点击确认退出登录", "settingsLogoutConfirmAction": "再次点击确认退出", @@ -202,6 +222,7 @@ "errorServiceUnavailable": "服务暂时不可用,请稍后重试", "errorServerGeneric": "服务异常,请稍后重试", "errorRequestGeneric": "请求失败,请稍后重试", + "errorProfileDeleteFailed": "删除账号失败,请稍后重试", "errorRunLimitExceeded": "本次会话追问次数已达上限,请新起一卦", "errorDivinationPayloadRequired": "缺少六爻输入数据,请重新起卦", "divinationScreenTitle": "起卦", diff --git a/backend/alembic/versions/20260407_0002_add_invite_codes.py b/backend/alembic/versions/20260407_0002_add_invite_codes.py index 97abd41..05c085d 100644 --- a/backend/alembic/versions/20260407_0002_add_invite_codes.py +++ b/backend/alembic/versions/20260407_0002_add_invite_codes.py @@ -124,7 +124,7 @@ def upgrade() -> None: lifetime_spent, version ) - VALUES (new.id, 100, 0, 100, 0, 0) + VALUES (new.id, 60, 0, 60, 0, 0) ON CONFLICT (user_id) DO NOTHING; INSERT INTO public.points_ledger ( @@ -144,8 +144,8 @@ def upgrade() -> None: v_ledger_id, new.id, 1, - 100, - 100, + 60, + 60, 'register', null, null, @@ -269,7 +269,7 @@ def downgrade() -> None: lifetime_spent, version ) - VALUES (new.id, 100, 0, 100, 0, 0) + VALUES (new.id, 60, 0, 60, 0, 0) ON CONFLICT (user_id) DO NOTHING; INSERT INTO public.points_ledger ( @@ -289,8 +289,8 @@ def downgrade() -> None: v_ledger_id, new.id, 1, - 100, - 100, + 60, + 60, 'register', null, null, diff --git a/backend/alembic/versions/20260409_0004_update_signup_welcome_points_to_60.py b/backend/alembic/versions/20260409_0004_update_signup_welcome_points_to_60.py new file mode 100644 index 0000000..a632a44 --- /dev/null +++ b/backend/alembic/versions/20260409_0004_update_signup_welcome_points_to_60.py @@ -0,0 +1,54 @@ +"""update signup welcome points from 100 to 60 + +Revision ID: 20260409_0004 +Revises: 20260407_0003 +Create Date: 2026-04-09 00:00:00 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "20260409_0004" +down_revision: Union[str, Sequence[str], None] = "20260407_0003" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def _rewrite_signup_function(*, from_points: int, to_points: int) -> None: + op.execute( + f""" + DO $$ + DECLARE + v_def text; + v_from_points text := '{from_points}'; + v_to_points text := '{to_points}'; + BEGIN + SELECT pg_get_functiondef('public.initialize_profile_and_invite_code_on_signup()'::regprocedure) + INTO v_def; + + v_def := regexp_replace( + v_def, + 'VALUES \\(new\\.id,\\s*' || v_from_points || ',\\s*0,\\s*' || v_from_points || ',\\s*0,\\s*0\\)', + 'VALUES (new.id, ' || v_to_points || ', 0, ' || v_to_points || ', 0, 0)' + ); + + v_def := regexp_replace( + v_def, + E'\\n\\s*' || v_from_points || ',\\n\\s*' || v_from_points || ',\\n\\s*''register''', + E'\\n ' || v_to_points || ',\\n ' || v_to_points || ',\\n ''register''' + ); + + EXECUTE v_def; + END; + $$; + """ + ) + + +def upgrade() -> None: + _rewrite_signup_function(from_points=100, to_points=60) + + +def downgrade() -> None: + _rewrite_signup_function(from_points=60, to_points=100) diff --git a/backend/src/services/base/supabase.py b/backend/src/services/base/supabase.py index 5df94f9..728dab6 100644 --- a/backend/src/services/base/supabase.py +++ b/backend/src/services/base/supabase.py @@ -271,6 +271,76 @@ class SupabaseService(BaseServiceProvider): return signed_url raise RuntimeError("Invalid signed url payload") + async def delete_prefix(self, *, bucket: str, prefix: str) -> int: + normalized_prefix = prefix.strip("/") + + def _delete_prefix() -> int: + bucket_client = self._ensure_bucket_client(bucket) + list_objects = getattr(bucket_client, "list", None) + remove_objects = getattr(bucket_client, "remove", None) + if not callable(list_objects) or not callable(remove_objects): + raise RuntimeError("Supabase storage delete APIs are unavailable") + + offset = 0 + limit = 100 + total_deleted = 0 + + while True: + options = { + "limit": limit, + "offset": offset, + "sortBy": {"column": "name", "order": "asc"}, + } + try: + raw_entries = list_objects(normalized_prefix, options) + except TypeError: + raw_entries = list_objects(normalized_prefix) + + entries = raw_entries if isinstance(raw_entries, list) else [] + if not entries: + break + + paths: list[str] = [] + for entry in entries: + name: str | None = None + if isinstance(entry, dict): + raw_name = entry.get("name") + if isinstance(raw_name, str) and raw_name: + name = raw_name + else: + raw_name = getattr(entry, "name", None) + if isinstance(raw_name, str) and raw_name: + name = raw_name + if name is None: + continue + if normalized_prefix: + paths.append(f"{normalized_prefix}/{name}") + else: + paths.append(name) + + if paths: + remove_objects(paths) + total_deleted += len(paths) + + if len(entries) < limit: + break + offset += limit + + return total_deleted + + return await asyncio.to_thread(_delete_prefix) + + async def delete_auth_user(self, *, user_id: str) -> None: + def _delete_auth_user() -> None: + admin_client = self.get_admin_client() + auth_admin = getattr(getattr(admin_client, "auth", None), "admin", None) + delete_user = getattr(auth_admin, "delete_user", None) + if not callable(delete_user): + raise RuntimeError("Supabase admin delete_user API is unavailable") + delete_user(user_id) + + await asyncio.to_thread(_delete_auth_user) + def parse_signed_url(self, url: str) -> tuple[str, str]: from urllib.parse import urlparse diff --git a/backend/src/v1/users/router.py b/backend/src/v1/users/router.py index 08b70ec..1914132 100644 --- a/backend/src/v1/users/router.py +++ b/backend/src/v1/users/router.py @@ -1,6 +1,6 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, File, UploadFile +from fastapi import APIRouter, Depends, File, Response, UploadFile from v1.users.dependencies import get_user_service from v1.users.schemas import ( @@ -54,3 +54,11 @@ async def upload_avatar( service: UserService = Depends(get_user_service), ) -> ProfileResponse: return await service.upload_avatar(file) + + +@router.delete("/me", status_code=204) +async def delete_my_account( + service: UserService = Depends(get_user_service), +) -> Response: + await service.delete_account() + return Response(status_code=204) diff --git a/backend/src/v1/users/service.py b/backend/src/v1/users/service.py index fadfc2f..557ea4b 100644 --- a/backend/src/v1/users/service.py +++ b/backend/src/v1/users/service.py @@ -290,6 +290,46 @@ class UserService: await self.repository.save() return await self.get_profile() + async def delete_account(self) -> None: + user_id = str(self.current_user.id) + avatar_bucket = config.storage.avatar.bucket + avatar_prefix = f"{self.current_user.id}/" + + try: + await self.attachment_storage.delete_prefix( + bucket=avatar_bucket, + prefix=avatar_prefix, + ) + except Exception as exc: + logger.exception( + "Account deletion failed while cleaning avatar objects", + user_id=user_id, + bucket=avatar_bucket, + prefix=avatar_prefix, + ) + raise ApiProblemError( + status_code=502, + detail=problem_payload( + code="PROFILE_DELETE_FAILED", + detail="Failed to delete account data", + ), + ) from exc + + try: + await self.attachment_storage.delete_auth_user(user_id=user_id) + except Exception as exc: + logger.exception( + "Account deletion failed while deleting auth user", + user_id=user_id, + ) + raise ApiProblemError( + status_code=502, + detail=problem_payload( + code="PROFILE_DELETE_FAILED", + detail="Failed to delete account data", + ), + ) from exc + async def _resolve_avatar_url(self, avatar_path: str | None) -> str | None: if avatar_path is None: return None diff --git a/backend/tests/unit/test_user_service_delete_account.py b/backend/tests/unit/test_user_service_delete_account.py new file mode 100644 index 0000000..e83abac --- /dev/null +++ b/backend/tests/unit/test_user_service_delete_account.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from uuid import uuid4 + +import pytest + +from core.auth.models import CurrentUser +from core.http.errors import ApiProblemError +from v1.users.service import UserService + + +class _NoopRepository: + pass + + +class _FakeStorage: + def __init__(self) -> None: + self.deleted_prefix_calls: list[tuple[str, str]] = [] + self.deleted_auth_user_calls: list[str] = [] + + async def delete_prefix(self, *, bucket: str, prefix: str) -> int: + self.deleted_prefix_calls.append((bucket, prefix)) + return 0 + + async def delete_auth_user(self, *, user_id: str) -> None: + self.deleted_auth_user_calls.append(user_id) + + +@pytest.mark.asyncio +async def test_delete_account_success_calls_storage_cleanup_and_auth_delete() -> None: + user = CurrentUser(id=uuid4(), email="test@example.com") + storage = _FakeStorage() + service = UserService( + current_user=user, + repository=_NoopRepository(), # type: ignore[arg-type] + attachment_storage=storage, # type: ignore[arg-type] + ) + + await service.delete_account() + + assert storage.deleted_prefix_calls == [("avatars", f"{user.id}/")] + assert storage.deleted_auth_user_calls == [str(user.id)] + + +@pytest.mark.asyncio +async def test_delete_account_raises_profile_delete_failed_on_storage_cleanup_error() -> ( + None +): + user = CurrentUser(id=uuid4(), email="test@example.com") + + class _FailingStorage(_FakeStorage): + async def delete_prefix(self, *, bucket: str, prefix: str) -> int: + raise RuntimeError("storage unavailable") + + storage = _FailingStorage() + service = UserService( + current_user=user, + repository=_NoopRepository(), # type: ignore[arg-type] + attachment_storage=storage, # type: ignore[arg-type] + ) + + with pytest.raises(ApiProblemError) as exc_info: + await service.delete_account() + + err = exc_info.value + assert err.status_code == 502 + assert err.code == "PROFILE_DELETE_FAILED" + assert storage.deleted_auth_user_calls == [] + + +@pytest.mark.asyncio +async def test_delete_account_raises_profile_delete_failed_on_auth_delete_error() -> ( + None +): + user = CurrentUser(id=uuid4(), email="test@example.com") + + class _FailingStorage(_FakeStorage): + async def delete_auth_user(self, *, user_id: str) -> None: + raise RuntimeError("delete user failed") + + storage = _FailingStorage() + service = UserService( + current_user=user, + repository=_NoopRepository(), # type: ignore[arg-type] + attachment_storage=storage, # type: ignore[arg-type] + ) + + with pytest.raises(ApiProblemError) as exc_info: + await service.delete_account() + + err = exc_info.value + assert err.status_code == 502 + assert err.code == "PROFILE_DELETE_FAILED" + assert storage.deleted_prefix_calls == [("avatars", f"{user.id}/")] diff --git a/docs/bugs/2026-04-08-followup-entry-bug.md b/docs/bugs/2026-04-08-followup-entry-bug.md deleted file mode 100644 index 82aa639..0000000 --- a/docs/bugs/2026-04-08-followup-entry-bug.md +++ /dev/null @@ -1,89 +0,0 @@ -# Bug: 追问1次后无法进入查看历史记录 - -日期:2026-04-08 -状态:已确认(未修复) - -## 问题描述 - -追问1次之后追问入口关闭,但用户也没法点击进去查看追问的历史记录。 - -## 根因分析 - -`_loadFollowUpEligibility()` 方法(`divination_result_screen.dart:65-83`)将两个概念混为一谈: - -```dart -Future _loadFollowUpEligibility() async { - ... - final messages = await widget.divinationApi!.getSessionMessages( - threadId: widget.data.threadId!, - ); - final userCount = messages.where((msg) => msg.role == 'user').length; - ... - setState(() { - _canFollowUp = userCount < 2; // 同时控制"能追问"和"能进入" - ... - }); -} -``` - -按钮逻辑(`divination_result_screen.dart:367`): -```dart -onPressed: (!_canFollowUp || _followUpEligibilityLoading) - ? null // _canFollowUp = false 时按钮完全禁用 - : () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => FollowUpChatScreen(...), - ), - ); - }, -``` - -**问题**: -- `_canFollowUp = userCount < 2` 控制的是"是否还有追问配额" -- 但 `onPressed` 把它和"是否能进入查看历史"混用了 -- 导致用户追问1次后(userCount=2),`_canFollowUp=false`,按钮被禁用 -- 用户**无法进入追问页面查看历史记录** - -**业务逻辑分析**: -- 每 session 最多2次追问(首问1次 + 追问1次) -- 追问1次后,用户不能再发送新追问 -- 但**应该仍能进入查看历史记录** - -### 正确的逻辑应该是 - -```dart -// 追问配额判断 -_canFollowUp = userCount < 2; - -// 能否进入查看历史(与追问次数无关,只要有历史消息就能进入) -_canEnterFollowUpChat = messages.isNotEmpty; // 或者只要有 threadId 就能进 - -// 按钮文案 -buttonText = _canFollowUp ? l10n.followUpEntryHint : l10n.followUpViewHistory; - -// 按钮是否禁用 -onPressed = (_followUpEligibilityLoading) - ? null // 只在加载中时禁用 - : () { ... } // 始终可点击进入查看 -``` - -## 证据 - -- 结果页代码:`apps/lib/features/divination/presentation/screens/divination_result_screen.dart` - - `_buildFollowUpBar`: 行 338-394 - - `_loadFollowUpEligibility`: 行 65-83 - - 按钮 onPress: 行 367 -- 追问聊天页:`apps/lib/features/divination/presentation/screens/follow_up_chat_screen.dart` - -## 修复方向 - -1. 分离"能否追问"和"能否进入查看历史"的逻辑 -2. 按钮文案根据状态显示不同内容(能追问显示追问提示,已用完显示"查看历史") -3. 只要有 `threadId`,用户就应该能进入查看历史 - ---- - -## 相关文档 - -- 随访工程计划:`docs/plans/2026-04-08-followup-session-history-eng-plan.md` diff --git a/docs/bugs/2026-04-08-followup-loading-and-voice-bug.md b/docs/bugs/2026-04-08-followup-loading-and-voice-bug.md deleted file mode 100644 index 65e5230..0000000 --- a/docs/bugs/2026-04-08-followup-loading-and-voice-bug.md +++ /dev/null @@ -1,261 +0,0 @@ -# Bug: 追问页面 UI 问题 - -日期:2026-04-08 -状态:已确认(未修复) - -## Bug 1: 追问页发送时顶部和消息下方重复加载UI - -### 问题描述 - -在追问页面,用户输入信息发送后,顶部和消息下方同时出现加载UI,但顶部的加载UI是多余的。 - -### 根因分析 - -**文件**:`apps/lib/features/divination/presentation/screens/follow_up_chat_screen.dart` - -**问题代码**: - -1. 顶部 step 指示器(第 85-124 行): -```dart -if (_sending && _currentStepName != null) - Container( - // 显示 step 进度,如 "解读中..."、"推理中..." - child: Row( - children: [ - CircularProgressIndicator(...), - Text(_stepLabel(_currentStepName!)), - ], - ), - ) -``` - -2. 消息下方 streaming placeholder(第 565-584 行): -```dart -isStreamingPlaceholder - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator(...), - Text(l10n.followUpGenerating), // "生成中..." - ], - ) -``` - -**事件触发流程**: - -| 顺序 | 事件 | 顶部指示器 | 消息下方 | -|------|------|----------|---------| -| 1 | `_submitText()` 调用 | 无 | streaming placeholder 显示 "生成中..." | -| 2 | `STEP_STARTED(stepName='divination')` | 显示 "解读中..." | 继续显示 | -| 3 | `STEP_FINISHED` | 消失 | 继续显示 | -| 4 | `STEP_STARTED(stepName='worker')` | 显示 "推理中..." | **同时显示** "生成中..." | -| 5 | `STEP_FINISHED` | 消失 | 继续显示 | -| 6 | `TEXT_MESSAGE_END` | 无 | placeholder 消失,显示真实内容 | - -**问题**:在 worker 阶段(步骤 4),顶部显示 "推理中..." 同时消息下方显示 "生成中...",造成重复反馈。用户只需要一个加载反馈即可。 - -**修复方向**: - -1. **方案A**:只在 divination 阶段显示顶部 step 指示器,worker 阶段隐藏(因为 streaming placeholder 已经提供反馈) - - ```dart - // 第 85 行修改 - if (_sending && _currentStepName != null && _currentStepName != 'worker') - ``` - -2. **方案B**:移除顶部 step 指示器,完全依赖 streaming placeholder - -3. **方案C**:在 worker 阶段隐藏 streaming placeholder,只显示顶部指示器 - -推荐 **方案A**,因为: -- divination 阶段没有 streaming placeholder,需要顶部指示器提供反馈 -- worker 阶段有 streaming placeholder,不需要顶部指示器 -- 改动最小,只加一个条件 - ---- - -## Bug 2: 语音模式录制时缺少动画UI - -### 问题描述 - -根据 social-app 的实现,按住说话模式下录音时应该有动画效果,但 eryao 的实现只有文字,没有动画。 - -### 根因分析 - -**对比**: - -| 功能 | social-app | eryao | -|------|------------|-------| -| 录音中动画 | `recordingAnimation` widget(外部传入) | 只有文字 "录音中..." | -| 录音提示文字 | 带动画的 widget | 只有文字 | -| 录音中隐藏输入区域 | `IgnorePointer` + `Opacity` | 未实现 | - -**eryao 当前实现**(`message_composer.dart:159-172`): -```dart -if (_isRecording) { - if (!showRecordingInlineFeedback) { - return Text(recordingText, ...); - } - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(width: 16, height: 16), // 空白占位 - const SizedBox(height: AppSpacing.xs), - Text(recordingText, ...), // "录音中..." - const SizedBox(height: AppSpacing.xs), - Text(recordingHintText, ...), // "上滑取消" - ], - ); -} -``` - -**social-app 实现**(`message_composer.dart:222-239`): -```dart -if (_isRecording) { - if (!showRecordingInlineFeedback) { - return Text(resolvedRecordingText, ...); - } - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - recordingAnimation, // 动画widget(外部传入) - const SizedBox(height: AppSpacing.xs), - Text(resolvedRecordingText, ...), - const SizedBox(height: AppSpacing.xs), - Text(resolvedRecordingHintText, ...), - ], - ); -} -``` - -**差异**: -1. `recordingAnimation` widget 缺失 - 需要外部传入,eryao 直接用空白占位 -2. 录音中图标/按钮没有动画效果 - -**修复方向**: - -1. 在 `FollowUpChatScreen` 或其父组件中创建 `recordingAnimation` widget -2. 将 `recordingAnimation` 通过 `MessageComposer` 传入 -3. 参考 social-app 的实现,使用脉冲动画或波形动画 - -**参考实现**(需要添加 `recording_animation.dart`): - -```dart -class RecordingAnimation extends StatefulWidget { - final double size; - final Color color; - - @override - State createState() => _RecordingAnimationState(); -} - -class _RecordingAnimationState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _scaleAnimation; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - duration: const Duration(milliseconds: 1000), - vsync: this, - )..repeat(reverse: true); - _scaleAnimation = Tween(begin: 1.0, end: 1.2).animate( - CurvedAnimation(parent: _controller, curve: Curves.easeInOut), - ); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _scaleAnimation, - builder: (context, child) { - return Transform.scale( - scale: _scaleAnimation.value, - child: Container( - width: widget.size, - height: widget.size, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: widget.color.withValues(alpha: 0.3), - ), - child: Icon(Icons.mic, color: widget.color, size: widget.size * 0.6), - ), - ); - }, - ); - } -} -``` - ---- - -## Bug 2 & 3: 直接复用 social-app 输入框(不需要 plus 按钮) - -### 需求 - -**直接复用** social-app 的 `MessageComposer`,但**不需要 plus 按钮**。 - -### 复用清单 - -| 功能 | social-app | eryao | 处理 | -|------|------------|-------|------| -| `recordingAnimation` | required Widget | 空白占位 | **需要添加** | -| 双层阴影 | 有 | 无 | **需要添加** | -| `AppRadius.xxl` | 用于圆角 | 无此值 | **需要添加** | -| Plus 按钮 | 有 | 无 | **不需要**,保持无 | -| 图标 | `LucideIcons` | Material Icons | 保持 Material Icons | - -### 实现步骤 - -#### 1. 添加 `AppRadius.xxl` 到 `design_tokens.dart` - -```dart -class AppRadius { - static const double sm = 8; - static const double md = 12; - static const double lg = 16; - static const double xl = 20; - static const double xxl = 32; // 新增 - static const double full = 999; -} -``` - -#### 2. 创建 `RecordingAnimation` widget - -放在 `apps/lib/shared/widgets/` 下,参考 social-app 的脉冲动画效果。 - -#### 3. 修改 `MessageComposer` - -- 添加 `recordingAnimation` 参数(required) -- 添加双层阴影 -- 使用 `AppRadius.xxl` 替代 `AppRadius.full` -- 移除 plus 按钮(eryao 原有的无 plus 逻辑保持不变) -- 保持 Material Icons - -#### 4. 修改 `FollowUpChatScreen` - -- 创建 `RecordingAnimation` 实例 -- 传入 `MessageComposer` - ---- - -## 相关文件 - -- `apps/lib/features/divination/presentation/screens/follow_up_chat_screen.dart` -- `apps/lib/shared/widgets/message_composer.dart` -- `apps/lib/shared/widgets/recording_animation.dart`(新建) -- `apps/lib/shared/theme/design_tokens.dart` -- `/home/qzl/Code/social-app/apps/lib/shared/widgets/message_composer.dart` - -## 修复优先级 - -1. **高优先级**:Bug 1(重复加载UI) -2. **高优先级**:Bug 2 & 3(直接复用 social-app 输入框) diff --git a/docs/bugs/2026-04-08-followup-sign-level-regeneration.md b/docs/bugs/2026-04-08-followup-sign-level-regeneration.md deleted file mode 100644 index 99e1e05..0000000 --- a/docs/bugs/2026-04-08-followup-sign-level-regeneration.md +++ /dev/null @@ -1,200 +0,0 @@ -# Bug: 追问时 agent_output 被重新生成,导致 sign_level 被覆盖 - -日期:2026-04-08 -状态:已确认根因(未修复) - -## 问题描述 - -首次解卦完成后,用户继续追问时,agent 会重新生成 `agent_output`,重新计算卦的结论和标签。 - -**具体表现**: -- 首次解卦结论:`中下签` -- 追问后结论:`下下签`(同一个卦,结果被重新生成) - -## 根因分析 - -**问题定位**:`backend/src/core/agentscope/runtime/runner.py` - -### 根因链条 - -1. **`runner.py:execute()` 方法(第 70-133 行)** - - 无论 `runtime_mode` 是 `chat` 还是 `follow_up`,都会执行以下逻辑: - - ```python - # 第 105-119 行:始终推导卦象 - derived_divination = self._resolve_derived_divination(run_input=run_input) - await self._emit_step_event( - pipeline=pipeline, - run_input=run_input, - step_name="divination", - event_type="DIVINATION_DERIVED", # <-- 追问时不应发射此事件 - ... - ) - ``` - -2. **`runner.py:_execute_worker_step()` 方法(第 200-245 行)** - - 始终将 `derived_divination` 传递给 worker: - - ```python - # 第 294-302 行:始终将 divination_derived 放入 worker_output - await emitter.emit_final_text_end( - worker_output={ - **worker_payload.model_dump(mode="json", exclude_none=True), - "divination_derived": derived_divination.model_dump(...), # <-- 追问时不应包含 - }, - ... - ) - ``` - -3. **`stage_emitter.py:emit_final_text_end()` 方法(第 46-73 行)** - - 始终将所有字段放入 `TEXT_MESSAGE_END` 事件: - - ```python - payload = { - "messageId": message_id, - "role": "assistant", - "stage": self._stage, - "status": worker_output.get("status"), - "sign_level": worker_output.get("sign_level"), # <-- 追问时不应有 - "conclusion": worker_output.get("conclusion", []), # <-- 追问时不应有 - "focus_points": worker_output.get("focus_points", []), - "advice": worker_output.get("advice", []), - "keywords": worker_output.get("keywords", []), - "answer": worker_output.get("answer", ""), - "error": worker_output.get("error"), - "divination_derived": worker_output.get("divination_derived"), # <-- 追问时不应有 - ... - } - ``` - -4. **`store.py:_persist_text_message()` 方法(第 125-160 行)** - - 从事件中提取所有字段并完整存储: - - ```python - worker_output_fields = ( - "status", - "sign_level", # <-- 追问时被重新生成并覆盖 - "conclusion", # <-- 追问时被重新生成并覆盖 - "focus_points", - "advice", - "keywords", - "answer", - "error", - "divination_derived", # <-- 追问时被重新生成 - "ui_hints", - ) - ``` - -### 问题本质 - -`runtime_mode=follow_up` 时,系统仍在: -1. 重新推导卦象(调用 `_resolve_derived_divination`) -2. 发射完整的 `DIVINATION_DERIVED` 事件 -3. 生成包含所有结构化字段的 `worker_output` -4. 将所有字段存储到数据库 - -但根据工程计划(`docs/plans/2026-04-08-followup-session-history-eng-plan.md:37-40`),追问时的预期行为是: - -``` -[一次追问] -user -> /agent/runs(runtime_mode=follow_up) - -> assistant(content [+ optional metadata.agent_output.answer]) -``` - -即:追问时只输出 `answer`(内容),不重新生成卦象结构。 - -## 证据 - -### 数据库证据 - -Session `015fe0f9-0500-43ab-911a-4ce8e3160032`: - -| 时间 | 角色 | sign_level | divination_derived | 问题 | -|------|------|------------|-------------------|------| -| 05:21:19 | user | - | - | 首问问题 | -| 05:21:28 | assistant | 中下签 | 完整 | 首答(正确) | -| 05:22:24 | user | - | - | 追问 | -| 05:22:33 | assistant | **下下签** | 完整 | 追问答(sign_level 被重新生成) | - -两个 assistant 消息的 `divination_derived` 完全相同,但 `sign_level` 不同,证明是重新生成的。 - -## 修复方向 - -### 1. `runner.py` - -在 `execute()` 方法中,根据 `runtime_mode` 决定是否推导卦象: - -```python -async def execute(self, ...): - runtime_mode = self._resolve_runtime_mode(run_input=run_input) - - if runtime_mode == RuntimeMode.CHAT: - derived_divination = self._resolve_derived_divination(run_input=run_input) - await self._emit_step_event(...) # DIVINATION_DERIVED - else: - derived_divination = None # follow_up 不推导 -``` - -### 2. `stage_emitter.py` - -`emit_final_text_end()` 根据 `runtime_mode` 决定发送哪些字段: - -```python -async def emit_final_text_end(self, ..., runtime_mode: str): - payload = {"messageId": ..., "role": "assistant", "stage": self._stage} - - if runtime_mode == "chat": - payload.update({ - "status": worker_output.get("status"), - "sign_level": worker_output.get("sign_level"), - "conclusion": worker_output.get("conclusion", []), - "focus_points": worker_output.get("focus_points", []), - "advice": worker_output.get("advice", []), - "keywords": worker_output.get("keywords", []), - "divination_derived": worker_output.get("divination_derived"), - ... - }) - else: # follow_up - payload["answer"] = worker_output.get("answer", "") -``` - -### 3. `store.py` - -`_persist_text_message()` 根据 `runtime_mode` 决定提取哪些字段: - -```python -async def _persist_text_message(self, ...): - runtime_mode = self._resolve_runtime_mode(event=event) - - if runtime_mode == "chat": - worker_output_fields = ("status", "sign_level", "conclusion", ...) - else: # follow_up - worker_output_fields = ("answer",) # 只存储 answer -``` - -### 4. `runtime_models.py` - -`resolve_worker_output_model()` 应根据 `runtime_mode` 返回不同 schema: - -```python -def resolve_worker_output_model(runtime_mode: RuntimeMode = RuntimeMode.CHAT) -> type[WorkerAgentOutputLite]: - if runtime_mode == RuntimeMode.FOLLOW_UP: - return WorkerAgentOutputLite # 只有 answer - return AgentOutput # 完整结构(继承自 WorkerAgentOutputRich) -``` - -## 相关文件 - -- `backend/src/core/agentscope/runtime/runner.py` - 主编排逻辑 -- `backend/src/core/agentscope/runtime/stage_emitter.py` - 事件发射 -- `backend/src/core/agentscope/events/store.py` - 事件持久化 -- `backend/src/schemas/agent/runtime_models.py` - 输出 schema 定义 - -## 相关文档 - -- 工程计划:`docs/plans/2026-04-08-followup-session-history-eng-plan.md` -- 协议文档:`docs/protocols/divination/divination-run-protocol.md` diff --git a/docs/bugs/2026-04-08-home-history-only-shows-4-items.md b/docs/bugs/2026-04-08-home-history-only-shows-4-items.md deleted file mode 100644 index a14986e..0000000 --- a/docs/bugs/2026-04-08-home-history-only-shows-4-items.md +++ /dev/null @@ -1,90 +0,0 @@ -# Bug: 首页历史 Session 只显示4条,"更多"按钮缺失 - -日期:2026-04-08 -状态:功能缺失(未完成) - -## 问题描述 - -前端主页接收后端传来的历史 session,永远只显示四个,"more"按钮不见了。 - -## 根因分析 - -### 1. 前端硬编码 `take(4)` - -`apps/lib/features/home/presentation/screens/home_screen.dart:287`: -```dart -children: historyItems.take(4).map((item) { -``` - -这是在 commit `6e82053`(重构首页为底部导航栏布局)时**有意添加**的设计选择,用于限制首页展示的历史记录数量。 - -### 2. 前端未实现"更多"按钮 - -- `l10n.more` 本地化字符串存在(`'更多'`/`'More'`),定义于 `apps/lib/l10n/app_localizations_zh.dart:115` 和 `app_localizations_en.dart:116` -- 但搜索整个 Dart 代码库,`l10n.more` **没有任何地方使用它** -- "更多"功能从未被实现 - -### 3. 后端 `hasMore` 硬编码为 `False` - -`backend/src/v1/agent/service.py:655-659`: -```python -return HistorySnapshotResponse( - scope="history_sessions_latest_assistant", - threadId=None, - day=None, - hasMore=False, # 硬编码,未实际计算 - messages=messages, -) -``` - -后端 schema 定义了 `hasMore` 字段(`schemas.py:238`),但 service 层返回时**硬编码为 `False`**,从未实际计算是否还有更多数据。 - -### 4. 后端 API 行为正确 - -`backend/src/v1/agent/service.py:641-646`: -```python -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, # 后端返回最多50条 -) -``` - -后端正确返回最多 50 条历史 session,前端只是没有利用这些数据。 - -## 问题性质 - -| 层级 | 现象 | 性质 | -|------|------|------| -| 后端 | `hasMore=False` 硬编码 | 缺陷:响应语义不正确 | -| 前端 | `take(4)` 只显示4条 | 设计选择:有意的 UI 限制 | -| 前端 | 无"更多"按钮 | 功能缺失:有 `l10n.more` 但未实现 | - -## 证据 - -- 前端代码:`apps/lib/features/home/presentation/screens/home_screen.dart:287` -- 后端代码:`backend/src/v1/agent/service.py:655` -- 本地化字符串:`apps/lib/l10n/app_localizations_zh.dart:115` -- 提交记录:`6e82053`(feat(home): 重构首页为底部导航栏布局) - -## 修复方向 - -### 方案 A:实现完整分页(推荐) - -1. **后端**:实现真正的 `hasMore` 计算逻辑 - - 添加 `offset` 参数支持分页 - - 实际计算 `hasMore = total_count > offset + limit` - -2. **前端**:实现"更多"按钮或无限滚动 - - 监听滚动位置,滚动到底部时加载更多 - - 或添加"更多"按钮手动触发加载 - -### 方案 B:移除不存在的字符串 - -如果业务确定只需要显示4条,则: -- 移除 `l10n.more` 本地化字符串,避免误导 - -## 相关文档 - -- 工程计划:`docs/plans/2026-04-05-divination-history-profile-eng-plan.md` -- 随访计划:`docs/plans/2026-04-08-followup-session-history-eng-plan.md` diff --git a/docs/plans/notification.md b/docs/plans/notification.md new file mode 100644 index 0000000..865cc5e --- /dev/null +++ b/docs/plans/notification.md @@ -0,0 +1,131 @@ +你是一个资深系统设计与代码分析助手。你的任务不是立刻编写代码,而是先深入理解当前项目中与“通知系统”相关的现有实现,然后基于现有代码结构输出一份可靠、可落地的实现方案。 + +本次任务聚焦于 App 通知系统设计,重点包括但不限于: +- 通知中心(notification list / inbox) +- 用户通知存储 +- 已读 / 已看状态 +- 推送触达状态 +- 前台实时通知 +- 后台/离线系统推送 +- 新版本通知、活动通知等业务通知类型 +- Flutter 客户端、后端服务、Supabase 之间的协作方式 + +你的首要目标是“理解现状并制定方案”,而不是直接进入编码。 + +请严格遵循以下工作方式: + +1. 先理解现有代码,再做设计 +- 主动查找项目中与通知相关的现有代码、模块、接口、表结构、状态管理、服务封装和配置文件。 +- 特别关注以下内容: + - Flutter 端是否已有本地通知、消息中心、badge、页面入口、深链跳转 + - 后端是否已有 notification / message / event / push / reminder 等相关模型、接口或 service + - Supabase 中是否已有相关表、RLS、Realtime、设备 token 存储 + - 是否已有 APNs / FCM / flutter_local_notifications / Firebase Messaging / Supabase Realtime 等集成痕迹 + - 是否已有版本检查机制、活动通知机制、用户收件箱机制 +- 不要假设项目是空白的。必须优先复用现有架构与已有能力。 + +2. 基于现有架构设计,而不是脱离项目另起炉灶 +- 方案必须尽量贴合当前项目的技术栈、目录结构、分层方式、命名风格和已有约束。 +- 优先考虑如何在现有模块上扩展,而不是重新设计一整套无关架构。 +- 如果当前实现存在明显缺陷或冲突,可以指出问题,但仍要给出“在现有基础上渐进演进”的方案。 + +3. 明确区分几个概念 +你在分析和设计时,必须区分以下概念,避免混淆: +- 应用内通知记录 +- 系统推送通知 +- 前台实时同步 +- 本地通知 +- 已看状态 +- 已读状态 +- 推送是否成功 +- 用户是否真正查看 + +4. 方案输出要覆盖的核心问题 +在最终方案中,至少要回答以下问题: +- 现有代码里已经有什么,缺什么 +- 通知数据应该如何建模 +- 是否需要 notifications / notification_receipts / user_push_devices 等表 +- 已读、已看、点击、删除等状态如何设计 +- Flutter 端如何读取通知列表、显示未读数、更新已读状态 +- Supabase Realtime 在这个项目里适合承担什么职责 +- APNs / FCM 或其他推送通道应该如何接入 +- 后端应该如何组织通知写入、fanout、推送发送、状态回写 +- 新版本通知与活动通知如何落地 +- 如何保证权限安全,例如 RLS、用户只能访问自己的通知 +- 如何分阶段实施,避免一次性改动过大 + +5. 输出必须先分析,后给建议 +不要一上来直接写“建议这样做”。 +你必须先给出: +- 当前代码现状梳理 +- 已有能力 +- 缺失点 +- 架构约束 +然后再给出推荐方案。 + +6. 不直接修改代码 +- 本轮目标是产出实现方案,而不是直接提交代码。 +- 除非我明确要求,否则不要直接创建文件、修改代码或生成迁移。 +- 可以提出建议的文件改动点,但不要直接实现。 + +请按以下结构输出: + +# 1. 需求理解 +- 这次通知系统要解决的核心问题 +- 涉及的通知类型 +- 系统边界(Flutter / Backend / Supabase / Push Provider) + +# 2. 现有代码调研结果 +- 已发现的相关模块 +- 已有能力 +- 可复用部分 +- 当前缺口 +- 潜在冲突或风险 + +# 3. 当前架构判断 +- 当前项目更适合采用什么通知架构 +- 为什么 +- 哪些方案不适合当前项目 + +# 4. 推荐实现方案 +至少包括: +- 数据模型设计 +- 状态字段设计 +- 客户端交互流程 +- 服务端处理流程 +- 实时通知与系统推送的职责划分 +- 已读/已看/触达状态方案 +- 版本通知与活动通知方案 +- 权限与安全策略 + +# 5. 分阶段落地计划 +请拆分为多个阶段,例如: +- 第一阶段:最小可用通知中心 +- 第二阶段:接入系统推送 +- 第三阶段:完善版本通知/活动通知/统计能力 +每个阶段说明: +- 目标 +- 改动范围 +- 主要任务 +- 依赖项 +- 风险点 +- 验收标准 + +# 6. 建议改动清单 +- 建议新增或修改的表 +- 建议新增或修改的后端模块 +- 建议新增或修改的 Flutter 模块 +- 建议新增的接口 / RPC / service +- 建议新增的配置项 + +# 7. 最终推荐 +- 推荐采用的总体方案 +- 推荐原因 +- 不确定点 +- 实施优先级排序 + +额外要求: +- 如果代码库中已经存在通知、提醒、消息、推送等相近实现,优先尝试整合,而不是重复建设。 +- 如果某些信息无法从当前代码中确认,要明确写出“不确定项”和“推断依据”。 +- 方案必须可执行、可渐进落地,避免空泛。 +- 优先给出最贴合当前代码库的设计,不要输出与项目现状脱节的理想化架构。 diff --git a/docs/plans/points-audit-and-register-bonus-plan.md b/docs/plans/points-audit-and-register-bonus-plan.md new file mode 100644 index 0000000..0f8f2ca --- /dev/null +++ b/docs/plans/points-audit-and-register-bonus-plan.md @@ -0,0 +1,419 @@ +# 积分审计与注册赠分策略改造计划(gstack / plan-eng-review) + +## 1. 目标与结论 + +本计划解决三个问题: + +1. 用户删除账号后,积分与成本审计数据不能随业务数据一起丢失。 +2. 同邮箱重复注册时,不应再次拿到注册赠分。 +3. 积分消耗审计必须记录真实 `input_tokens` / `output_tokens` / `cost`,不能再写占位值。 +4. LLM 失败/取消时若平台已产生真实成本,该成本不转嫁用户积分,但必须进入审计账本。 + +结论:采用 **双账本 + 资格账本**。 + +- 保留业务账本:`user_points`、`points_ledger`(在线业务能力) +- 新增审计账本:`points_audit_ledger`(不可变审计) +- 新增资格账本:`register_bonus_claims`(注册奖励去重) +- 注册赠分策略从 DB trigger 移出,改为应用层策略(配置驱动) + +--- + +## 2. 系统边界 + +### 2.1 业务域(可删除) + +- `user_points`:余额视图 +- `points_ledger`:业务流水 +- `messages` / `sessions`:会话与消息 + +### 2.2 审计域(不可级联删除) + +- `points_audit_ledger`:审计流水,保留用户快照和成本快照(含用户承担/平台承担归属) +- `register_bonus_claims`:注册奖励领取资格记录 + +### 2.3 策略域(应用层) + +- `register_bonus_points` 配置项(默认 60) +- `register_bonus_hmac_key` 配置项(环境变量注入) +- 首登赠分是否发放由服务层决定,不写死在数据库 trigger + +--- + +## 3. 现状问题(基于当前代码) + +1. 注册赠分写死在 DB trigger。 + 当前函数:`public.initialize_profile_and_invite_code_on_signup()`,历史上出现过 100/60 改动漂移。 + +2. 积分消费审计写占位值。 + 在 `backend/src/v1/points/service.py` 中,`consume_successful_run_points` 写入 `input_tokens=0`、`output_tokens=0`、`cost=0`。 + +3. 删号会丢审计线索。 + 当前业务删除路径会清理业务数据,缺少独立审计账本保留策略。 + +--- + +## 4. 数据模型(按项目风格精简命名) + +说明:不引入陌生“模板字段”,沿用当前 `points_ledger` 命名风格。 + +### 4.1 新表:`points_audit_ledger` + +- `id` UUID PK +- `event_id` VARCHAR(64) UNIQUE NOT NULL +- `user_id_snapshot` UUID NULL +- `user_email_snapshot` TEXT NULL +- `change_type` VARCHAR(16) NOT NULL +- `biz_type` VARCHAR(16) NULL +- `biz_id` UUID NULL +- `direction` SMALLINT NOT NULL +- `amount` BIGINT NOT NULL +- `balance_after` BIGINT NOT NULL +- `billed_to` VARCHAR(16) NOT NULL -- `user` | `platform` +- `run_id` VARCHAR(128) NULL +- `request_id` VARCHAR(128) NULL +- `input_tokens` INTEGER NOT NULL DEFAULT 0 +- `output_tokens` INTEGER NOT NULL DEFAULT 0 +- `cost` NUMERIC(12,6) NOT NULL DEFAULT 0 +- `metadata` JSONB NOT NULL DEFAULT '{}' +- `created_at` TIMESTAMPTZ NOT NULL DEFAULT now() +- `updated_at` TIMESTAMPTZ NOT NULL DEFAULT now() + +索引建议: + +- `uq_points_audit_ledger_event_id` +- `ix_points_audit_ledger_user_id_created_at` (`user_id_snapshot`, `created_at DESC`) +- `ix_points_audit_ledger_change_type_created_at` (`change_type`, `created_at DESC`) + +### 4.2 新表:`register_bonus_claims` + +- `id` UUID PK +- `email_hash` VARCHAR(64) UNIQUE NOT NULL +- `user_email_snapshot` TEXT NOT NULL +- `first_user_id` UUID NULL +- `grant_event_id` VARCHAR(64) UNIQUE NOT NULL +- `created_at` TIMESTAMPTZ NOT NULL DEFAULT now() +- `updated_at` TIMESTAMPTZ NOT NULL DEFAULT now() + +注:`email_hash` 由标准化邮箱(trim + lower)计算(HMAC-SHA256,key 来自 `register_bonus_hmac_key`)。 + +--- + +## 5. 数据流设计 + +### 5.1 注册赠分流程(应用层,非 trigger) + +```text +[用户首登/注册完成] + -> PointsPolicyService.load(register_bonus_points) + -> normalize(email) -> email_hash + -> INSERT register_bonus_claims(email_hash, ...) + - 成功: 继续发放积分 + - 唯一冲突: 说明历史已领取,跳过发放 + -> 更新 user_points + -> 写 points_ledger + -> 写 points_audit_ledger + -> commit +``` + +### 5.2 运行消耗积分流程(写真实成本) + +```text +[run completed] + -> 从持久化消息/会话聚合真实 usage + (input_tokens, output_tokens, cost) + -> PointsService.consume_successful_run_points(...) + -> 更新 user_points + -> 写 points_ledger + -> 写 points_audit_ledger(真实 usage) + -> commit +``` + +### 5.3 运行失败/取消但平台发生成本流程(不扣用户,记平台账) + +```text +[run failed/canceled] + -> 从持久化消息/事件聚合真实 usage + -> 若 cost > 0: + - 不调用用户扣分 + - 写 points_audit_ledger( + direction=0, + amount=0, + billed_to='platform', + input_tokens/output_tokens/cost=真实值, + metadata.reason='run_failed_or_canceled_platform_billed' + ) + -> commit +``` + +### 5.4 删除账号流程 + +```text +[delete account] + -> 删除 user_points / points_ledger / sessions / messages / profile / auth + -> 保留 points_audit_ledger / register_bonus_claims +``` + +--- + +## 6. 失败模式与处理 + +### 6.1 双写不一致(P0) + +- 场景:`points_ledger` 写成功,`points_audit_ledger` 写失败。 +- 策略:同事务写入,任一失败全部回滚。 + +### 6.2 并发重复注册(P0) + +- 场景:同邮箱并发首登,发放多次。 +- 策略:`register_bonus_claims.email_hash` 唯一约束 + 冲突即跳过。 + +### 6.3 邮箱规范化不一致(P1) + +- 场景:`User@A.com` 与 `user@a.com` 被当成不同人。 +- 策略:统一 normalize(trim + lower)后再 hash。 + +### 6.4 成本快照缺失(P1) + +- 场景:run 成功但 usage 聚合取不到,写入 0。 +- 策略: + - 业务是否扣分与成本写入解耦:允许扣分,但审计需标记 `metadata.usage_missing=true` + - 记录 warning 日志并纳入告警指标 + +### 6.5 失败/取消真实成本归属(P0) + +- 场景:LLM 回调失败或用户取消,但上游已计费。 +- 策略: + - 不扣用户积分(`user_points`、`points_ledger`不变) + - 审计账本强制落一条平台承担记录(`billed_to='platform'`) + - 该记录必须包含真实 `input_tokens` / `output_tokens` / `cost` + +--- + +## 7. 信任边界与安全 + +1. `user_email_snapshot` 必须来自服务端认证上下文,不接受客户端传入。 +2. `input_tokens/output_tokens/cost` 必须来自服务端持久化记录,不接受客户端上报。 +3. 审计表只允许后端 service-role 写入,不暴露客户端写接口。 +4. `register_bonus_claims` 不应被普通业务接口更新/删除。 +5. `register_bonus_hmac_key` 仅后端可读,不下发客户端,不写日志。 + +--- + +## 8. 实施步骤(最小改动优先) + +### Phase 1: 协议与配置 + +- 更新协议文档: + - `docs/protocols/common/user-points-chat-data-protocol.md` + - 新增“审计留存与注册奖励策略”章节 +- 新增配置:`register_bonus_points`(默认 60) + +### Phase 2: 数据库迁移 + +- 新增表:`points_audit_ledger` +- 新增表:`register_bonus_claims` +- 不改现有 `points_ledger`、`user_points` 结构 + +### Phase 3: 服务层改造 + +- 移除 trigger 中注册送分逻辑(trigger 只保留 profile/invite 初始化) +- 在应用层增加注册奖励发放逻辑(带资格检查) +- 在积分消费路径改造为真实 usage 写审计 +- 在失败/取消路径增加平台承担成本审计(不扣用户) + +### Phase 4: 删除链路校验 + +- 删除账号后验证业务表清理 +- 验证审计表与资格表仍可查 + +--- + +## 9. 测试覆盖计划 + +### 9.0 P0 测试门槛(实现前锁定) + +以下测试为上线前阻断项,任一缺失不得合并: + +1. **幂等回放**:同一 `event_id` 重放不重复写 `points_audit_ledger`。 +2. **注册送分去重**:同邮箱(normalize 后)重复注册不重复发放积分。 +3. **事务一致性**:业务账本写入成功但审计写入失败时,整体回滚。 +4. **删除后重注册**:删号后同邮箱重注册仍不再发放首登奖励。 +5. **失败/取消审计**:run 失败与取消场景写审计但不扣积分。 +6. **成本归属**:失败/取消且 `cost>0` 的记录必须为 `billed_to='platform'`。 + +### 9.1 单元测试 + +- 邮箱 normalize/hash 一致性 +- 注册奖励配置读取与默认值 +- usage 聚合函数(含空值和异常值) + +### 9.2 集成测试 + +- 首次注册发放奖励成功 +- 同邮箱重复注册不再发放 +- 并发注册仅一次成功发放 +- 消费积分写入真实 tokens/cost 审计 +- 失败/取消且平台发生成本时,写平台承担审计且不扣用户积分 +- 删号后审计数据保留 + +### 9.3 回归测试 + +- 现有积分余额查询和扣分逻辑不回归 +- 邀请码流程不回归 + +--- + +## 10. 文件级改造清单 + +### 数据库 / 模型 + +- `backend/alembic/versions/*` 新增迁移:创建两张新表 +- `backend/src/models/points_audit_ledger.py` 新增 +- `backend/src/models/register_bonus_claims.py` 新增 + +### 积分服务与仓储 + +- `backend/src/v1/points/repository.py`:新增审计写入、资格检查方法 +- `backend/src/v1/points/service.py`: + - 新增注册奖励发放入口(配置驱动) + - 消费路径写真实 usage 审计 + - 失败/取消路径写平台承担成本审计 + +### 运行时调用链 + +- `backend/src/core/agentscope/runtime/tasks.py`: + - 在扣分点传入真实 usage(或可计算上下文) + - 在 run 异常/取消路径传入 usage 并落平台承担审计 + +### 协议文档 + +- `docs/protocols/common/user-points-chat-data-protocol.md` 更新 + +--- + +## 11. 取舍说明 + +### 为什么不直接改 `points_ledger` 为审计表 + +- 会把在线业务与审计诉求耦合在一张表,后续权限和迁移风险高。 +- 当前最小改动方案是新增审计表,保持业务链路稳定。 + +### 为什么保留 `event_id` + +- `id` 是技术主键,只保证行唯一。 +- `event_id` 是业务幂等键,防重放、防重试重复记账、支持跨表对账。 + +--- + +## 12. 未决事项 + +1. `user_email_snapshot` 是否明文存储,还是仅内部可解密存储。 +2. 审计数据保留时长(默认建议至少 1 年)。 +3. 成本单位与精度是否统一沿用 `NUMERIC(12,6)`。 + +--- + +## 13. PR 拆分与执行顺序(可直接实现) + +### PR1:数据库与协议落地(不改业务行为) + +目标:先建立新数据边界,不改变线上积分逻辑。 + +改动范围: + +- `backend/alembic/versions/*`:新增迁移,创建 `points_audit_ledger`、`register_bonus_claims` +- `backend/src/models/points_audit_ledger.py`:新增模型 +- `backend/src/models/register_bonus_claims.py`:新增模型 +- `docs/protocols/common/user-points-chat-data-protocol.md`:补审计与注册送分策略契约 + +验收标准: + +- 迁移可执行、可回滚 +- 新表索引与唯一约束生效 +- 协议文档与表结构一致 + +测试要求: + +- 迁移 smoke test +- 约束与索引存在性校验 + +### PR2:注册送分策略迁移到应用层(去 trigger 固化) + +目标:把注册送分从 DB trigger 移到应用层唯一触发点(注册回调)。 + +改动范围: + +- `backend/src/v1/points/service.py`:新增注册奖励发放入口与资格校验 +- `backend/src/v1/points/repository.py`:新增 `register_bonus_claims` 检查/写入 +- `backend/src/core/config/settings.py`(或等价配置入口):新增 `register_bonus_points` +- `backend/src/core/config/settings.py`(或等价配置入口):新增 `register_bonus_hmac_key` +- 相关注册回调调用链文件:接入 `grant_register_bonus_if_eligible(...)` +- 迁移调整:更新 trigger,移除注册送分写入逻辑,仅保留 profile/invite 初始化 + +验收标准: + +- 新注册触发一次赠分 +- 同邮箱重复注册不再赠分 +- 配置变更可控制赠分值(默认 60) +- 邮箱哈希稳定且不可逆(同邮箱同哈希,不暴露明文) + +测试要求: + +- 并发注册去重测试(唯一约束 + 冲突路径) +- 删除账号后同邮箱重注册不赠分 +- event_id 幂等回放不重复发放 +- 缺失 `register_bonus_hmac_key` 时服务启动失败(fail fast) + +### PR3:真实成本审计与删除链路联调 + +目标:将 run 真实 usage 写入审计,并覆盖成功/失败/取消三种对话轮次;失败/取消场景发生真实成本时记平台承担。 + +改动范围: + +- `backend/src/v1/points/service.py`:消费路径审计写入(真实 tokens/cost) +- `backend/src/v1/points/repository.py`:新增 `append_audit_ledger(...)` +- `backend/src/core/agentscope/runtime/tasks.py`:传递该轮次必要上下文 +- 账号删除服务链路:确认保留 `points_audit_ledger/register_bonus_claims` + +验收标准: + +- 成功对话:扣分 + 审计 +- 失败/取消对话:不扣分 + 审计(若有成本则 `billed_to='platform'`) +- 审计中的 `input_tokens/output_tokens/cost` 为真实值,不再占位 0 + +测试要求: + +- 成功/失败/取消三路径集成测试 +- 事务一致性测试(业务写成功 + 审计写失败 -> 回滚) +- 删除后审计保留验证 +- 失败/取消 + `cost>0` 平台承担场景回归测试 + +### PR4:观测与运维保障(建议同迭代完成) + +目标:避免审计静默失真。 + +改动范围: + +- 指标与日志: + - `points_audit_write_failed_total` + - `points_usage_missing_total` +- 告警阈值:连续失败或短时突增告警 +- 运维文档:异常重放与人工核对流程 + +验收标准: + +- 审计写入失败可被监控发现 +- usage 缺失可被监控发现并可追溯到事件 + +--- + +## 14. 实施完成定义(DoD) + +满足以下全部条件才算完成: + +1. 计划中的 P0 测试门槛全部通过。 +2. 注册赠分不再依赖 DB trigger 写死值。 +3. `points_audit_ledger` 记录真实 usage,覆盖成功/失败/取消。 +3. `points_audit_ledger` 记录真实 usage,覆盖成功/失败/取消;失败/取消有真实成本时归属 `platform`。 +4. 删除账号后业务数据清理,审计与资格数据保留。 +5. 关键失败有指标与告警,不允许静默失败。 diff --git a/docs/protocols/common/http-error-codes.md b/docs/protocols/common/http-error-codes.md index 48dc933..4035c2a 100644 --- a/docs/protocols/common/http-error-codes.md +++ b/docs/protocols/common/http-error-codes.md @@ -40,6 +40,7 @@ This document is the source of truth for backend RFC7807 `code` values consumed |---|---:|---|---| | `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 | +| `PROFILE_DELETE_FAILED` | 502 | Backend failed to complete account hard deletion | Show delete-failed message and allow retry | ## Avatar diff --git a/docs/protocols/profile/profile-protocol.md b/docs/protocols/profile/profile-protocol.md index 80476ff..89f94de 100644 --- a/docs/protocols/profile/profile-protocol.md +++ b/docs/protocols/profile/profile-protocol.md @@ -1,6 +1,6 @@ # Profile Protocol (Frontend <-> Backend) -This document defines the canonical backend contract for user profile read/write and avatar upload signing. +This document defines the canonical backend contract for user profile read/write, avatar upload signing, and account hard deletion. Protocol verification status: @@ -9,7 +9,7 @@ Protocol verification status: - Backend service source: `backend/src/v1/users/service.py` - Frontend mapping source: `apps/lib/features/settings/data/apis/profile_api.dart` - Storage config source: `backend/src/core/config/settings.py` -- Current status: aligned +- Current status: profile/avatar aligned; account deletion backend implemented (frontend wiring pending) ## Compatibility strategy @@ -23,6 +23,7 @@ Protocol verification status: - Update settings: `PATCH /api/v1/users/me/settings` - Create avatar upload url: `POST /api/v1/users/me/avatar/upload-url` - Upload avatar directly: `POST /api/v1/users/me/avatar` (multipart) +- Delete account and personal data (hard delete): `DELETE /api/v1/users/me` ## Auth and trust boundary @@ -179,3 +180,57 @@ Behavior: - All errors must follow RFC7807 `application/problem+json`. - `code` values must be registered in `docs/protocols/common/http-error-codes.md`. + +## Account hard deletion contract + +### `DELETE /api/v1/users/me` + +Purpose: + +- Permanently delete the current account and associated personal data from developer records. +- This is hard delete behavior, not soft delete and not temporary deactivation. + +Request: + +- No request body. +- Auth required (same JWT trust boundary as other `/users/me/*` routes). + +Success response: + +- `204 No Content` + +Behavior contract: + +1. Deletion target is always the authenticated user (`sub`), never a client-supplied `user_id`. +2. Deletion must remove account identity and associated user data for this product scope. +3. Deletion must be irreversible from client perspective. +4. After successful deletion, existing local session must be treated as invalid by client and backend. + +### Deletion scope (current product contract) + +The delete operation must remove data owned by the authenticated user in the following domains: + +- Identity: `auth.users` row for current user. +- Profile: `profiles` row. +- Points: `user_points`, `points_ledger` rows linked to user. +- Chat: `sessions`, `messages` rows linked to user/session ownership. +- Avatar storage objects under prefix `avatars/{user_id}/`. + +Notes: + +- If future legal/compliance requirements introduce mandatory retention, retained fields must be explicitly documented and user-visible in deletion UI copy. +- This protocol version assumes no regulated retention exemption for current product scope. + +### Error semantics + +The route follows common RFC7807 error payload and registry codes. Expected HTTP classes: + +- `401` when auth is missing/invalid. +- `403` when auth context is valid but action is not permitted by policy. +- `409` when server cannot complete deletion due to a conflict that requires user action. +- `5xx` for unexpected server/upstream failure (must not fail silently). + +### Consistency and idempotency expectations + +- API behavior is request-idempotent at user intent level: once account is deleted, repeating the action should not recreate state and should not produce partial undeleted data. +- Client should treat any post-deletion authenticated call failure as terminal session invalidation and force logout flow.