feat: 添加账号删除功能

This commit is contained in:
qzl
2026-04-10 10:40:44 +08:00
parent 17a1303f00
commit 46513829cd
30 changed files with 1510 additions and 664 deletions
+16
View File
@@ -305,6 +305,21 @@ class _EryaoAppState extends State<EryaoApp> {
return saved;
}
Future<void> _deleteAccount() async {
await _profileApi.deleteAccount();
if (!mounted) {
return;
}
setState(() {
_profileSettings = ProfileSettingsV1.defaultsForLocale(_locale);
_historyRecords = const <DivinationResultData>[];
_creditsBalance = 0;
_loadedProfileUserEmail = null;
_loadedHistoryUserEmail = null;
_loadedCreditsUserEmail = null;
});
}
Future<void> _saveProfileSettings(ProfileSettingsV1 next) async {
try {
final oldLanguage = _profileSettings.preferences.interfaceLanguage;
@@ -415,6 +430,7 @@ class _EryaoAppState extends State<EryaoApp> {
onDivinationCompleted: _handleDivinationCompleted,
onDeleteHistorySession: _handleHistorySessionDeleted,
onLogout: _authBloc.logout,
onDeleteAccount: _deleteAccount,
);
}
@@ -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;
}
+7 -1
View File
@@ -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);
+27 -1
View File
@@ -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<void> deleteSession({required String refreshToken}) async {
await _apiClient.deleteNoContent(
final response = await _apiClient.rawDio.delete<Map<String, dynamic>>(
'/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<String, dynamic>) {
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',
);
}
@@ -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) {
try {
await _authApi.deleteSession(refreshToken: refreshToken);
} on ApiProblem catch (problem) {
if (problem.status != 401 ||
problem.code != 'AUTH_REFRESH_TOKEN_INVALID') {
rethrow;
}
}
}
} finally {
await clearLocalSession();
@@ -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<void> logout() async {
try {
await _repository.logout();
} catch (error, 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,
);
}
_logger.info(message: 'User logged out');
_state = const AuthState(status: AuthStatus.unauthenticated);
notifyListeners();
}),
);
}
Future<void> handleUnauthorized401() async {
@@ -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<void> Function(String threadId) onDeleteHistorySession;
final Future<void> Function() onLogout;
final Future<void> Function() onDeleteAccount;
@override
State<HomeScreen> createState() => _HomeScreenState();
@@ -116,6 +118,7 @@ class _HomeScreenState extends State<HomeScreen> {
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<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
final Future<void> Function() onLogout;
final Future<void> Function() onDeleteAccount;
@override
Widget build(BuildContext context) {
@@ -528,6 +533,7 @@ class _ProfileTab extends StatelessWidget {
onSaveProfile: onSaveProfile,
onUploadAvatar: onUploadAvatar,
onLogout: onLogout,
onDeleteAccount: onDeleteAccount,
);
}
}
@@ -87,6 +87,10 @@ class ProfileApi {
return _toSettings(data);
}
Future<void> deleteAccount() async {
await _apiClient.deleteNoContent('/api/v1/users/me');
}
ProfileSettingsV1 _toSettings(Map<String, dynamic> json) {
final settingsRaw = json['settings'];
final preferencesRaw = settingsRaw is Map<String, dynamic>
@@ -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<void> Function() onDeleteAccount;
@override
State<AccountDeleteScreen> createState() => _AccountDeleteScreenState();
}
class _AccountDeleteScreenState extends State<AccountDeleteScreen> {
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<void> _confirmDelete() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return const _DeleteConfirmDialog();
},
);
if (confirmed != true || !mounted) {
return;
}
await _deleteAccount();
}
Future<void> _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),
),
),
],
),
],
),
),
);
}
}
@@ -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<void> Function(ProfileSettingsV1 settings) onSettingsChanged;
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
final Future<void> Function() onLogout;
final Future<void> Function() onDeleteAccount;
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
onSaveProfile;
@@ -119,6 +122,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
],
),
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<SettingsScreen> {
);
}
Future<void> _openAccountDelete() async {
final deleted = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(
builder: (_) =>
AccountDeleteScreen(onDeleteAccount: widget.onDeleteAccount),
),
);
if (deleted != true) {
return;
}
await widget.onLogout();
if (!mounted) {
return;
}
Navigator.of(context).popUntil((route) => route.isFirst);
}
Future<void> _confirmLogout() async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await showDialog<bool>(
@@ -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)
+21
View File
@@ -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",
+84
View File
@@ -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:
+53
View File
@@ -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.';
+45
View File
@@ -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 => '本次会话追问次数已达上限,请新起一卦';
+21
View File
@@ -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": "起卦",
@@ -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,
@@ -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)
+70
View File
@@ -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
+9 -1
View File
@@ -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)
+40
View File
@@ -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
@@ -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}/")]
@@ -1,89 +0,0 @@
# Bug: 追问1次后无法进入查看历史记录
日期:2026-04-08
状态:已确认(未修复)
## 问题描述
追问1次之后追问入口关闭,但用户也没法点击进去查看追问的历史记录。
## 根因分析
`_loadFollowUpEligibility()` 方法(`divination_result_screen.dart:65-83`)将两个概念混为一谈:
```dart
Future<void> _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<void>(
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`
@@ -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<RecordingAnimation> createState() => _RecordingAnimationState();
}
class _RecordingAnimationState extends State<RecordingAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1000),
vsync: this,
)..repeat(reverse: true);
_scaleAnimation = Tween<double>(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 输入框)
@@ -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`
@@ -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`
+131
View File
@@ -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. 最终推荐
- 推荐采用的总体方案
- 推荐原因
- 不确定点
- 实施优先级排序
额外要求:
- 如果代码库中已经存在通知、提醒、消息、推送等相近实现,优先尝试整合,而不是重复建设。
- 如果某些信息无法从当前代码中确认,要明确写出“不确定项”和“推断依据”。
- 方案必须可执行、可渐进落地,避免空泛。
- 优先给出最贴合当前代码库的设计,不要输出与项目现状脱节的理想化架构。
@@ -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-SHA256key 来自 `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` 被当成不同人。
- 策略:统一 normalizetrim + 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. 关键失败有指标与告警,不允许静默失败。
@@ -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
+57 -2
View File
@@ -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.