feat(feedback): implement user feedback collection system with email reporting

Backend:
- Add user_feedback table with RLS policy
- Create feedback submission API (multipart/form-data)
- Implement xlsx report generation with embedded images
- Add scheduled email delivery via Feishu SMTP
- Create HTML email templates (daily_report, no_feedback)

Frontend:
- Add feedback screen with type selection and image picker
- Support anonymous submission via skipAuth flag
- Collect device info and app version

Protocol:
- Document feedback API contract and error codes
- Update http-error-codes.md with FEEDBACK_* codes
This commit is contained in:
qzl
2026-04-20 12:49:54 +08:00
parent 913ed26f8d
commit 6a2a9d2c87
46 changed files with 4768 additions and 9 deletions
+20
View File
@@ -57,8 +57,28 @@ ERYAO_STORAGE__AVATAR__BUCKET=avatars
ERYAO_STORAGE__SIGNED_URL_TTL_SECONDS=600
ERYAO_STORAGE__ATTACHMENT__MAX_SIZE_MB=20
ERYAO_STORAGE__AVATAR__MAX_SIZE_MB=2
ERYAO_STORAGE__FEEDBACK__BUCKET=feedback-images
ERYAO_STORAGE__FEEDBACK__MAX_SIZE_MB=5
ERYAO_STORAGE__RETENTION_DAYS=30
############
# Feedback Report
############
ERYAO_FEEDBACK_REPORT__EMAIL=support@example.com
ERYAO_FEEDBACK_REPORT__CRON=0 10 * * *
ERYAO_FEEDBACK_REPORT__ENABLED=false
############
# Email SMTP 配置(飞书企业邮箱)
############
ERYAO_EMAIL__HOST=smtp.feishu.cn
ERYAO_EMAIL__PORT=465
ERYAO_EMAIL__USERNAME=robot@xunmee.com
ERYAO_EMAIL__PASSWORD=
ERYAO_EMAIL__USE_SSL=true
ERYAO_EMAIL__FROM_ADDRESS=robot@xunmee.com
ERYAO_EMAIL__FROM_NAME=Eryao 反馈系统
############
# LLM API KEY
############
@@ -0,0 +1,22 @@
{"check": "dependency_installed", "description": "验证 openpyxl 已安装", "command": "uv pip show openpyxl"}
{"check": "migration_file_exists", "description": "验证迁移文件已创建", "command": "ls backend/alembic/versions/*create_user_feedback.py"}
{"check": "schema_file_exists", "description": "验证 Schema 文件已创建", "command": "test -f backend/src/v1/feedback/schemas.py"}
{"check": "schema_strict_validation", "description": "验证 Schema 使用 extra=forbid 强约束", "command": "grep -q 'extra.*forbid' backend/src/v1/feedback/schemas.py"}
{"check": "model_file_exists", "description": "验证模型文件已创建", "command": "test -f backend/src/models/feedback.py"}
{"check": "repository_file_exists", "description": "验证 Repository 文件已创建", "command": "test -f backend/src/v1/feedback/repository.py"}
{"check": "service_file_exists", "description": "验证 Service 文件已创建", "command": "test -f backend/src/v1/feedback/service.py"}
{"check": "router_file_exists", "description": "验证路由文件已创建", "command": "test -f backend/src/v1/feedback/router.py"}
{"check": "router_registered", "description": "验证路由已注册", "command": "grep -q 'feedback' backend/src/v1/router.py"}
{"check": "error_codes_added", "description": "验证错误码已添加", "command": "grep -q 'FEEDBACK_' docs/protocols/common/http-error-codes.md"}
{"check": "frontend_model_exists", "description": "验证前端模型已创建", "command": "test -f apps/lib/features/settings/data/models/feedback.dart"}
{"check": "frontend_api_exists", "description": "验证前端 API 已创建", "command": "test -f apps/lib/features/settings/data/apis/feedback_api.dart"}
{"check": "frontend_screen_exists", "description": "验证前端反馈页已创建", "command": "test -f apps/lib/features/settings/presentation/screens/feedback_screen.dart"}
{"check": "l10n_zh_added", "description": "验证中文翻译已添加", "command": "grep -q 'settingsFeedbackTitle' apps/lib/l10n/app_zh.arb"}
{"check": "l10n_en_added", "description": "验证英文翻译已添加", "command": "grep -q 'settingsFeedbackTitle' apps/lib/l10n/app_en.arb"}
{"check": "database_table_exists", "description": "验证数据库表已创建", "command": "psql -c '\\d user_feedback'"}
{"check": "api_submit_works", "description": "测试反馈提交 API(匿名用户)", "command": "curl -X POST http://localhost:8000/api/v1/feedback -H 'Content-Type: application/json' -d '{\"feedback_type\":\"bug\",\"content\":\"test\",\"images\":[],\"device_info\":{\"platform\":\"ios\",\"model\":\"test\"},\"app_version\":\"1.0\",\"os_version\":\"iOS 17\"}'"}
{"check": "api_validation_content_empty", "description": "测试 API 参数验证(内容为空)", "command": "curl -X POST http://localhost:8000/api/v1/feedback -H 'Content-Type: application/json' -d '{\"feedback_type\":\"bug\",\"content\":\"\",\"images\":[],\"device_info\":{\"platform\":\"ios\",\"model\":\"test\"},\"app_version\":\"1.0\",\"os_version\":\"iOS 17\"}' | grep -q 'FEEDBACK_CONTENT_EMPTY'"}
{"check": "api_validation_too_many_images", "description": "测试 API 参数验证(图片超限)", "command": "curl -X POST http://localhost:8000/api/v1/feedback -H 'Content-Type: application/json' -d '{\"feedback_type\":\"bug\",\"content\":\"test\",\"images\":[\"url1\",\"url2\",\"url3\",\"url4\"],\"device_info\":{\"platform\":\"ios\",\"model\":\"test\"},\"app_version\":\"1.0\",\"os_version\":\"iOS 17\"}' | grep -q 'FEEDBACK_TOO_MANY_IMAGES'"}
{"check": "frontend_builds", "description": "验证前端编译通过", "command": "cd apps && flutter analyze"}
{"check": "backend_typecheck", "description": "验证后端类型检查通过", "command": "uv run basedpyright backend/src/v1/feedback/"}
{"check": "backend_lint", "description": "验证后端 lint 通过", "command": "uv run ruff check backend/src/v1/feedback/"}
@@ -0,0 +1,20 @@
{"step": 1, "action": "add_dependency", "description": "添加 openpyxl 依赖", "command": "uv add openpyxl", "files": ["pyproject.toml"]}
{"step": 2, "action": "create_migration", "description": "创建 user_feedback 数据库表迁移", "files": ["backend/alembic/versions/20260417_1_create_user_feedback.py"]}
{"step": 3, "action": "create_storage_bucket", "description": "创建 Supabase Storage feedback bucket", "files": ["supabase/storage/feedback"]}
{"step": 4, "action": "create_schemas", "description": "创建 Pydantic Schema(强约束,extra=forbid", "files": ["backend/src/v1/feedback/schemas.py"]}
{"step": 5, "action": "create_model", "description": "创建 Feedback 数据库模型", "files": ["backend/src/models/feedback.py"]}
{"step": 6, "action": "create_repository", "description": "创建 FeedbackRepositoryCRUD 操作)", "files": ["backend/src/v1/feedback/repository.py"]}
{"step": 7, "action": "create_service", "description": "创建 FeedbackService(业务逻辑)", "files": ["backend/src/v1/feedback/service.py"]}
{"step": 8, "action": "create_router", "description": "创建 POST /api/v1/feedback 路由", "files": ["backend/src/v1/feedback/router.py"]}
{"step": 9, "action": "register_router", "description": "注册 feedback router 到主路由", "files": ["backend/src/v1/router.py"]}
{"step": 10, "action": "add_error_codes", "description": "添加反馈相关错误码到协议文档", "files": ["docs/protocols/common/http-error-codes.md"]}
{"step": 11, "action": "create_frontend_model", "description": "创建前端 Feedback 数据模型", "files": ["apps/lib/features/settings/data/models/feedback.dart"]}
{"step": 12, "action": "create_frontend_api", "description": "创建前端 FeedbackApi", "files": ["apps/lib/features/settings/data/apis/feedback_api.dart"]}
{"step": 13, "action": "create_frontend_repository", "description": "创建前端 FeedbackRepository", "files": ["apps/lib/features/settings/data/repositories/feedback_repository.dart"]}
{"step": 14, "action": "create_feedback_screen", "description": "创建反馈表单页(含图片上传组件)", "files": ["apps/lib/features/settings/presentation/screens/feedback_screen.dart"]}
{"step": 15, "action": "add_feedback_entry", "description": "在 SettingsScreen 添加反馈入口", "files": ["apps/lib/features/settings/presentation/screens/settings_screen.dart"]}
{"step": 16, "action": "add_l10n_zh", "description": "添加中文翻译", "files": ["apps/lib/l10n/app_zh.arb"]}
{"step": 17, "action": "add_l10n_en", "description": "添加英文翻译", "files": ["apps/lib/l10n/app_en.arb"]}
{"step": 18, "action": "add_l10n_zh_hant", "description": "添加繁体中文翻译", "files": ["apps/lib/l10n/app_zh_hant.arb"]}
{"step": 19, "action": "run_migration", "description": "执行数据库迁移", "command": "./infra/scripts/dev-migrate.sh migrate"}
{"step": 20, "action": "run_l10n_gen", "description": "生成国际化代码", "command": "cd apps && flutter gen-l10n"}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,75 @@
{
"name": "feat-user-feedback",
"title": "用户反馈投送功能",
"type": "feature",
"priority": "medium",
"dev_type": "fullstack",
"created_at": "2026-04-17",
"status": "planning",
"worktree": "feat-user-feedback",
"branch": "worktree/feat-user-feedback",
"description": "实现用户反馈投送功能:前端反馈表单 + 后端数据存储 + 图片上传。Phase 2 实现定时报告生成和邮件推送。",
"prd": "prd.md",
"phase": {
"current": 1,
"total": 3,
"description": "Phase 1: 前端反馈 + 后端存储 + 图片上传"
},
"tech_stack": {
"backend": {
"language": "Python",
"framework": "FastAPI",
"orm": "SQLAlchemy",
"task_queue": "Taskiq",
"xlsx_lib": "openpyxl"
},
"frontend": {
"language": "Dart",
"framework": "Flutter",
"storage": "Supabase Storage"
}
},
"features": {
"anonymous_feedback": true,
"image_upload": true,
"max_images": 3,
"max_content_length": 500,
"report_generation": false,
"email_notification": false
},
"constraints": {
"schema_strict": "所有字段使用 Pydantic Schema 强约束,extra=forbid",
"no_contact_email": "删除 contact_email 字段,不存储用户联系方式",
"no_admin_routes": "暂不实现管理员路由",
"config_via_settings": "客服邮箱和推送时间通过 Settings 环境变量配置"
},
"checklist": {
"backend": [
"添加 openpyxl 依赖",
"创建 user_feedback 表迁移(含 images JSONB 字段)",
"创建 Supabase Storage feedback bucket",
"创建 Pydantic Schema(强约束,extra=forbid",
"创建 Feedback 模型",
"创建 FeedbackRepositoryCRUD 操作)",
"创建 FeedbackService(业务逻辑)",
"创建 POST /api/v1/feedback 接口",
"添加错误码到协议文档"
],
"frontend": [
"创建 Feedback 数据模型",
"创建 FeedbackApi",
"创建 FeedbackRepository",
"创建 FeedbackScreen(含图片上传组件)",
"在 SettingsScreen 添加反馈入口",
"添加 l10n keys(中/英/繁)"
]
},
"notes": [
"Phase 1 只实现前端反馈 + 后端存储 + 图片上传",
"Phase 2 实现定时报告生成和邮件推送",
"使用 Python openpyxl 生成 xlsx 报告(技术栈统一)",
"Schema 使用 extra=forbid 禁止模糊字段",
"客服邮箱通过 FEEDBACK_REPORT_EMAIL 环境变量配置",
"推送时间通过 FEEDBACK_REPORT_CRON 环境变量配置"
]
}
+4
View File
@@ -20,6 +20,10 @@ class ApiClient {
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) async {
if (options.extra['skipAuth'] == true) {
handler.next(options);
return;
}
final token = await tokenProvider();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
@@ -73,16 +73,16 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> {
MainTab _currentTab = MainTab.home;
late final InviteRepository _inviteRepository;
late final ApiClient _apiClient;
@override
void initState() {
super.initState();
final inviteApi = InviteApi(
apiClient: ApiClient(
_apiClient = ApiClient(
baseUrl: appDependencies.backendUrl,
tokenProvider: widget.sessionStore.getToken,
),
);
final inviteApi = InviteApi(apiClient: _apiClient);
_inviteRepository = InviteRepositoryImpl(inviteApi: inviteApi);
WidgetsBinding.instance.addPostFrameCallback((_) {
_tryShowWelcomeDialog();
@@ -135,6 +135,7 @@ class _HomeScreenState extends State<HomeScreen> {
settings: widget.profileSettings,
coinBalance: widget.coinBalance,
inviteRepository: _inviteRepository,
apiClient: _apiClient,
onLocaleChanged: widget.onLocaleChanged,
onSettingsChanged: widget.onProfileSettingsChanged,
onSaveProfile: widget.onSaveProfile,
@@ -563,6 +564,7 @@ class _ProfileTab extends StatelessWidget {
required this.settings,
required this.coinBalance,
required this.inviteRepository,
required this.apiClient,
required this.onLocaleChanged,
required this.onSettingsChanged,
required this.onSaveProfile,
@@ -575,6 +577,7 @@ class _ProfileTab extends StatelessWidget {
final ProfileSettingsV1 settings;
final int coinBalance;
final InviteRepository inviteRepository;
final ApiClient apiClient;
final Future<void> Function(String languageTag) onLocaleChanged;
final Future<void> Function(ProfileSettingsV1 settings) onSettingsChanged;
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
@@ -590,6 +593,7 @@ class _ProfileTab extends StatelessWidget {
settings: settings,
coinBalance: coinBalance,
inviteRepository: inviteRepository,
apiClient: apiClient,
onInterfaceLanguageChanged: onLocaleChanged,
onSettingsChanged: onSettingsChanged,
onSaveProfile: onSaveProfile,
@@ -0,0 +1,85 @@
import 'package:dio/dio.dart';
import 'package:image_picker/image_picker.dart';
import '../../../../core/logging/logger.dart';
import '../../../../core/network/api_problem.dart';
import '../../../../data/network/api_client.dart';
import '../models/feedback.dart';
class FeedbackApi {
FeedbackApi({required ApiClient apiClient}) : _apiClient = apiClient;
final ApiClient _apiClient;
final Logger _logger = getLogger('features.settings.feedback_api');
Future<void> submitFeedback({
required FeedbackType type,
required String content,
required DeviceInfo deviceInfo,
required String appVersion,
required String osVersion,
required List<XFile> images,
required bool isAnonymous,
}) async {
final typeName = switch (type) {
FeedbackType.bug => 'bug',
FeedbackType.suggestion => 'suggestion',
FeedbackType.other => 'other',
};
final formData = FormData.fromMap({
'feedback_type': typeName,
'content': content,
'device_info':
'{"platform":"${deviceInfo.platform}","model":"${deviceInfo.model}"}',
'app_version': appVersion,
'os_version': osVersion,
});
for (final image in images) {
formData.files.add(
MapEntry(
'images',
await MultipartFile.fromFile(image.path, filename: image.name),
),
);
}
final options = isAnonymous
? Options(extra: {'skipAuth': true})
: Options();
try {
await _apiClient.rawDio.post<void>(
'/api/v1/feedback',
data: formData,
options: options,
);
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'Submit feedback failed',
error: error,
stackTrace: stackTrace,
);
throw _mapProblem(error);
}
}
ApiProblem _mapProblem(DioException error) {
final status = error.response?.statusCode ?? 500;
final data = error.response?.data;
if (data is Map<String, dynamic>) {
return ApiProblem(
status: status,
title: (data['title'] as String?) ?? 'Request failed',
detail: (data['detail'] as String?) ?? '',
code: data['code'] as String?,
);
}
return ApiProblem(
status: status,
title: 'Network error',
detail: error.message ?? 'Request failed',
);
}
}
@@ -0,0 +1,8 @@
enum FeedbackType { bug, suggestion, other }
class DeviceInfo {
const DeviceInfo({required this.platform, required this.model});
final String platform;
final String model;
}
@@ -0,0 +1,44 @@
import 'package:image_picker/image_picker.dart';
import '../apis/feedback_api.dart';
import '../models/feedback.dart';
abstract class FeedbackRepository {
Future<void> submitFeedback({
required FeedbackType type,
required String content,
required DeviceInfo deviceInfo,
required String appVersion,
required String osVersion,
required List<XFile> images,
required bool isAnonymous,
});
}
class FeedbackRepositoryImpl implements FeedbackRepository {
FeedbackRepositoryImpl({required FeedbackApi feedbackApi})
: _feedbackApi = feedbackApi;
final FeedbackApi _feedbackApi;
@override
Future<void> submitFeedback({
required FeedbackType type,
required String content,
required DeviceInfo deviceInfo,
required String appVersion,
required String osVersion,
required List<XFile> images,
required bool isAnonymous,
}) {
return _feedbackApi.submitFeedback(
type: type,
content: content,
deviceInfo: deviceInfo,
appVersion: appVersion,
osVersion: osVersion,
images: images,
isAnonymous: isAnonymous,
);
}
}
@@ -0,0 +1,389 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../../core/logging/logger.dart';
import '../../../../data/network/api_client.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../data/apis/feedback_api.dart';
import '../../data/models/feedback.dart';
import '../../data/repositories/feedback_repository.dart';
class FeedbackScreen extends StatefulWidget {
const FeedbackScreen({super.key, required this.apiClient});
final ApiClient apiClient;
@override
State<FeedbackScreen> createState() => _FeedbackScreenState();
}
class _FeedbackScreenState extends State<FeedbackScreen> {
final Logger _logger = getLogger('features.settings.feedback_screen');
final ImagePicker _imagePicker = ImagePicker();
final TextEditingController _contentController = TextEditingController();
FeedbackType _selectedType = FeedbackType.bug;
List<XFile> _selectedImages = [];
bool _isAnonymous = false;
bool _isSubmitting = false;
static const int _maxImages = 3;
static const int _maxContentSize = 500;
static const int _maxImageSizeBytes = 5 * 1024 * 1024;
@override
void dispose() {
_contentController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: colors.surfaceContainerLow,
appBar: AppBar(
title: Text(l10n.feedbackTitle),
centerTitle: true,
backgroundColor: colors.surfaceContainerLow,
surfaceTintColor: colors.surfaceContainerLow,
),
body: ListView(
padding: const EdgeInsets.fromLTRB(
AppSpacing.lg,
AppSpacing.md,
AppSpacing.lg,
AppSpacing.xxl,
),
children: [
Text(
l10n.feedbackTypeLabel,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.sm),
SegmentedButton<FeedbackType>(
showSelectedIcon: false,
segments: [
ButtonSegment(
value: FeedbackType.bug,
label: Text(l10n.feedbackTypeBug),
),
ButtonSegment(
value: FeedbackType.suggestion,
label: Text(l10n.feedbackTypeSuggestion),
),
ButtonSegment(
value: FeedbackType.other,
label: Text(l10n.feedbackTypeOther),
),
],
selected: {_selectedType},
onSelectionChanged: (selection) {
setState(() {
_selectedType = selection.first;
});
},
),
const SizedBox(height: AppSpacing.xl),
Text(
l10n.feedbackContentLabel,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.sm),
TextField(
controller: _contentController,
maxLines: 8,
maxLength: _maxContentSize,
decoration: InputDecoration(
hintText: l10n.feedbackContentHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppRadius.md),
),
),
),
const SizedBox(height: AppSpacing.xl),
Text(
l10n.feedbackImagesLabel,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.sm),
_buildImagePickerRow(colors),
const SizedBox(height: AppSpacing.xl),
CheckboxListTile(
value: _isAnonymous,
onChanged: (value) {
setState(() {
_isAnonymous = value ?? false;
});
},
title: Text(l10n.feedbackAnonymousLabel),
subtitle: Text(
l10n.feedbackAnonymousHint,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant),
),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
),
const SizedBox(height: AppSpacing.xl),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _isSubmitting ? null : _submit,
child: _isSubmitting
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colors.onPrimary,
),
)
: Text(l10n.feedbackSubmit),
),
),
],
),
);
}
Widget _buildImagePickerRow(ColorScheme colors) {
return Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
..._selectedImages.asMap().entries.map((entry) {
final index = entry.key;
final file = entry.value;
return Stack(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: colors.outlineVariant),
),
clipBehavior: Clip.antiAlias,
child: kIsWeb
? Image.network(
file.path,
fit: BoxFit.cover,
errorBuilder: (_, e, _) =>
const Icon(Icons.broken_image),
)
: Image.file(
File(file.path),
fit: BoxFit.cover,
errorBuilder: (_, e, _) =>
const Icon(Icons.broken_image),
),
),
Positioned(
top: 4,
right: 4,
child: GestureDetector(
onTap: () => _removeImage(index),
child: Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: colors.error,
shape: BoxShape.circle,
),
child: Icon(Icons.close, size: 14, color: colors.onError),
),
),
),
],
);
}),
if (_selectedImages.length < _maxImages)
GestureDetector(
onTap: _pickImage,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: colors.outlineVariant),
color: colors.surfaceContainerHighest,
),
child: Icon(
Icons.add_photo_alternate_outlined,
color: colors.onSurfaceVariant,
),
),
),
],
);
}
Future<void> _pickImage() async {
final l10n = AppLocalizations.of(context)!;
if (_selectedImages.length >= _maxImages) {
Toast.show(context, l10n.feedbackTooManyImages, type: ToastType.warning);
return;
}
XFile? picked;
try {
picked = await _imagePicker.pickImage(
source: ImageSource.gallery,
maxWidth: 1920,
imageQuality: 85,
requestFullMetadata: false,
);
} catch (error, stackTrace) {
_logger.error(
message: 'Image picker failed',
error: error,
stackTrace: stackTrace,
);
return;
}
if (picked == null || !mounted) return;
final fileSize = await picked.length();
if (fileSize > _maxImageSizeBytes) {
if (!mounted) return;
Toast.show(context, l10n.feedbackImageTooLarge, type: ToastType.warning);
return;
}
setState(() {
_selectedImages = [..._selectedImages, picked!];
});
}
void _removeImage(int index) {
setState(() {
_selectedImages = List.from(_selectedImages)..removeAt(index);
});
}
Future<void> _submit() async {
final l10n = AppLocalizations.of(context)!;
final content = _contentController.text.trim();
if (content.isEmpty) {
Toast.show(
context,
l10n.feedbackContentRequired,
type: ToastType.warning,
);
return;
}
if (content.length > _maxContentSize) {
Toast.show(context, l10n.feedbackContentTooLong, type: ToastType.warning);
return;
}
setState(() {
_isSubmitting = true;
});
try {
final feedbackApi = FeedbackApi(apiClient: widget.apiClient);
final repository = FeedbackRepositoryImpl(feedbackApi: feedbackApi);
await repository.submitFeedback(
type: _selectedType,
content: content,
deviceInfo: await _collectDeviceInfo(),
appVersion: await _appVersion(),
osVersion: await _osVersion(),
images: _selectedImages,
isAnonymous: _isAnonymous,
);
if (!mounted) return;
Toast.show(context, l10n.feedbackSuccess, type: ToastType.success);
Navigator.of(context).pop();
} catch (error, stackTrace) {
_logger.error(
message: 'Submit feedback failed',
error: error,
stackTrace: stackTrace,
);
if (!mounted) return;
Toast.show(context, l10n.errorRequestGeneric, type: ToastType.error);
} finally {
if (mounted) {
setState(() {
_isSubmitting = false;
});
}
}
}
Future<DeviceInfo> _collectDeviceInfo() async {
final deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await deviceInfo.androidInfo;
return DeviceInfo(
platform: 'android',
model: '${android.brand} ${android.model}',
);
} else if (Platform.isIOS) {
final ios = await deviceInfo.iosInfo;
return DeviceInfo(platform: 'ios', model: _iosDeviceName(ios));
}
return DeviceInfo(platform: defaultTargetPlatform.name, model: 'unknown');
}
String _iosDeviceName(IosDeviceInfo ios) {
final machine = ios.utsname.machine;
final deviceName = _iosDeviceMapping[machine] ?? machine;
return deviceName;
}
static const Map<String, String> _iosDeviceMapping = {
'iPhone13,2': 'iPhone 12',
'iPhone13,3': 'iPhone 12 Pro',
'iPhone13,4': 'iPhone 12 Pro Max',
'iPhone14,2': 'iPhone 13 Pro',
'iPhone14,3': 'iPhone 13 Pro Max',
'iPhone14,4': 'iPhone 13 mini',
'iPhone14,5': 'iPhone 13',
'iPhone15,2': 'iPhone 14 Pro',
'iPhone15,3': 'iPhone 14 Pro Max',
'iPhone14,7': 'iPhone 14',
'iPhone14,8': 'iPhone 14 Plus',
'iPhone15,4': 'iPhone 15',
'iPhone15,5': 'iPhone 15 Plus',
'iPhone16,1': 'iPhone 15 Pro',
'iPhone16,2': 'iPhone 15 Pro Max',
'iPhone17,1': 'iPhone 16 Pro',
'iPhone17,2': 'iPhone 16 Pro Max',
'iPhone17,3': 'iPhone 16',
'iPhone17,4': 'iPhone 16 Plus',
};
Future<String> _appVersion() async {
final info = await PackageInfo.fromPlatform();
return '${info.version}+${info.buildNumber}';
}
Future<String> _osVersion() async {
final deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await deviceInfo.androidInfo;
return 'Android ${android.version.release} (API ${android.version.sdkInt})';
} else if (Platform.isIOS) {
final ios = await deviceInfo.iosInfo;
return 'iOS ${ios.systemVersion}';
}
return defaultTargetPlatform.name;
}
}
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../../../data/network/api_client.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../../shared/widgets/app_modal_dialog.dart';
@@ -9,6 +10,7 @@ import '../../data/repositories/invite_repository.dart';
import 'account_delete_screen.dart';
import '../widgets/settings_section_widgets.dart';
import 'coin_center_screen.dart';
import 'feedback_screen.dart';
import 'general_settings_screen.dart';
import 'invite_screen.dart';
import 'legal_center_screen.dart';
@@ -21,6 +23,7 @@ class SettingsScreen extends StatefulWidget {
required this.settings,
required this.coinBalance,
required this.inviteRepository,
required this.apiClient,
required this.onInterfaceLanguageChanged,
required this.onSettingsChanged,
required this.onUploadAvatar,
@@ -33,6 +36,7 @@ class SettingsScreen extends StatefulWidget {
final ProfileSettingsV1 settings;
final int coinBalance;
final InviteRepository inviteRepository;
final ApiClient apiClient;
final Future<void> Function(String languageTag) onInterfaceLanguageChanged;
final Future<void> Function(ProfileSettingsV1 settings) onSettingsChanged;
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
@@ -125,6 +129,23 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
],
),
const SizedBox(height: AppSpacing.xl),
SettingsGroupCard(
children: [
SettingsMenuTile(
icon: Icons.feedback_outlined,
title: l10n.settingsFeedbackTitle,
tint: colors.primary,
background: colors.surfaceContainerHighest,
showDivider: false,
onTap: () => Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => FeedbackScreen(apiClient: widget.apiClient),
),
),
),
],
),
SettingsGroupCard(
children: [
SettingsMenuTile(
+19 -1
View File
@@ -487,5 +487,23 @@
"settingsDoNotSellTitle": "Personalized Ads",
"settingsDoNotSellDescription": "When off, your personal info won't be used for ad recommendations",
"settingsDoNotSellEnabled": "Off",
"settingsDoNotSellDisabled": "On"
"settingsDoNotSellDisabled": "On",
"settingsFeedbackTitle": "Feedback",
"feedbackTitle": "Feedback",
"feedbackTypeLabel": "Feedback Type",
"feedbackTypeBug": "Bug",
"feedbackTypeSuggestion": "Suggestion",
"feedbackTypeOther": "Other",
"feedbackContentLabel": "Content",
"feedbackContentHint": "Please describe your issue or suggestion in detail...",
"feedbackImagesLabel": "Add Screenshots (max 3)",
"feedbackAnonymousLabel": "Do not upload my personal information",
"feedbackAnonymousHint": "If checked, your user ID will not be collected. Device info will still be collected for troubleshooting.",
"feedbackSubmit": "Submit Feedback",
"feedbackSubmitting": "Submitting...",
"feedbackSuccess": "Thank you for your feedback. We will process it soon.",
"feedbackContentRequired": "Please enter feedback content",
"feedbackContentTooLong": "Feedback content cannot exceed 500 characters",
"feedbackTooManyImages": "Maximum 3 images allowed",
"feedbackImageTooLarge": "Image size cannot exceed 5MB"
}
+108
View File
@@ -2324,6 +2324,114 @@ abstract class AppLocalizations {
/// In zh, this message translates to:
/// **'已开启'**
String get settingsDoNotSellDisabled;
/// No description provided for @settingsFeedbackTitle.
///
/// In zh, this message translates to:
/// **'意见反馈'**
String get settingsFeedbackTitle;
/// No description provided for @feedbackTitle.
///
/// In zh, this message translates to:
/// **'意见反馈'**
String get feedbackTitle;
/// No description provided for @feedbackTypeLabel.
///
/// In zh, this message translates to:
/// **'反馈类型'**
String get feedbackTypeLabel;
/// No description provided for @feedbackTypeBug.
///
/// In zh, this message translates to:
/// **'问题反馈'**
String get feedbackTypeBug;
/// No description provided for @feedbackTypeSuggestion.
///
/// In zh, this message translates to:
/// **'功能建议'**
String get feedbackTypeSuggestion;
/// No description provided for @feedbackTypeOther.
///
/// In zh, this message translates to:
/// **'其他'**
String get feedbackTypeOther;
/// No description provided for @feedbackContentLabel.
///
/// In zh, this message translates to:
/// **'反馈内容'**
String get feedbackContentLabel;
/// No description provided for @feedbackContentHint.
///
/// In zh, this message translates to:
/// **'请详细描述您的问题或建议...'**
String get feedbackContentHint;
/// No description provided for @feedbackImagesLabel.
///
/// In zh, this message translates to:
/// **'添加截图(最多3张)'**
String get feedbackImagesLabel;
/// No description provided for @feedbackAnonymousLabel.
///
/// In zh, this message translates to:
/// **'不上传我的个人信息'**
String get feedbackAnonymousLabel;
/// No description provided for @feedbackAnonymousHint.
///
/// In zh, this message translates to:
/// **'勾选后将不采集您的用户ID,仅采集设备信息用于问题排查'**
String get feedbackAnonymousHint;
/// No description provided for @feedbackSubmit.
///
/// In zh, this message translates to:
/// **'提交反馈'**
String get feedbackSubmit;
/// No description provided for @feedbackSubmitting.
///
/// In zh, this message translates to:
/// **'提交中...'**
String get feedbackSubmitting;
/// No description provided for @feedbackSuccess.
///
/// In zh, this message translates to:
/// **'感谢您的反馈,我们会尽快处理'**
String get feedbackSuccess;
/// No description provided for @feedbackContentRequired.
///
/// In zh, this message translates to:
/// **'请输入反馈内容'**
String get feedbackContentRequired;
/// No description provided for @feedbackContentTooLong.
///
/// In zh, this message translates to:
/// **'反馈内容不能超过500字'**
String get feedbackContentTooLong;
/// No description provided for @feedbackTooManyImages.
///
/// In zh, this message translates to:
/// **'最多只能上传3张图片'**
String get feedbackTooManyImages;
/// No description provided for @feedbackImageTooLarge.
///
/// In zh, this message translates to:
/// **'图片大小不能超过5MB'**
String get feedbackImageTooLarge;
}
class _AppLocalizationsDelegate
+58
View File
@@ -1224,4 +1224,62 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsDoNotSellDisabled => 'On';
@override
String get settingsFeedbackTitle => 'Feedback';
@override
String get feedbackTitle => 'Feedback';
@override
String get feedbackTypeLabel => 'Feedback Type';
@override
String get feedbackTypeBug => 'Bug';
@override
String get feedbackTypeSuggestion => 'Suggestion';
@override
String get feedbackTypeOther => 'Other';
@override
String get feedbackContentLabel => 'Content';
@override
String get feedbackContentHint =>
'Please describe your issue or suggestion in detail...';
@override
String get feedbackImagesLabel => 'Add Screenshots (max 3)';
@override
String get feedbackAnonymousLabel => 'Do not upload my personal information';
@override
String get feedbackAnonymousHint =>
'If checked, your user ID will not be collected. Device info will still be collected for troubleshooting.';
@override
String get feedbackSubmit => 'Submit Feedback';
@override
String get feedbackSubmitting => 'Submitting...';
@override
String get feedbackSuccess =>
'Thank you for your feedback. We will process it soon.';
@override
String get feedbackContentRequired => 'Please enter feedback content';
@override
String get feedbackContentTooLong =>
'Feedback content cannot exceed 500 characters';
@override
String get feedbackTooManyImages => 'Maximum 3 images allowed';
@override
String get feedbackImageTooLarge => 'Image size cannot exceed 5MB';
}
+108
View File
@@ -1171,6 +1171,60 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get settingsDoNotSellDisabled => '已开启';
@override
String get settingsFeedbackTitle => '意见反馈';
@override
String get feedbackTitle => '意见反馈';
@override
String get feedbackTypeLabel => '反馈类型';
@override
String get feedbackTypeBug => '问题反馈';
@override
String get feedbackTypeSuggestion => '功能建议';
@override
String get feedbackTypeOther => '其他';
@override
String get feedbackContentLabel => '反馈内容';
@override
String get feedbackContentHint => '请详细描述您的问题或建议...';
@override
String get feedbackImagesLabel => '添加截图(最多3张)';
@override
String get feedbackAnonymousLabel => '不上传我的个人信息';
@override
String get feedbackAnonymousHint => '勾选后将不采集您的用户ID,仅采集设备信息用于问题排查';
@override
String get feedbackSubmit => '提交反馈';
@override
String get feedbackSubmitting => '提交中...';
@override
String get feedbackSuccess => '感谢您的反馈,我们会尽快处理';
@override
String get feedbackContentRequired => '请输入反馈内容';
@override
String get feedbackContentTooLong => '反馈内容不能超过500字';
@override
String get feedbackTooManyImages => '最多只能上传3张图片';
@override
String get feedbackImageTooLarge => '图片大小不能超过5MB';
}
/// The translations for Chinese, using the Han script (`zh_Hant`).
@@ -2096,4 +2150,58 @@ class AppLocalizationsZhHant extends AppLocalizationsZh {
@override
String get settingsDoNotSellDisabled => '已開啟';
@override
String get settingsFeedbackTitle => '意見回饋';
@override
String get feedbackTitle => '意見回饋';
@override
String get feedbackTypeLabel => '回饋類型';
@override
String get feedbackTypeBug => '問題回饋';
@override
String get feedbackTypeSuggestion => '功能建議';
@override
String get feedbackTypeOther => '其他';
@override
String get feedbackContentLabel => '回饋內容';
@override
String get feedbackContentHint => '請詳細描述您的問題或建議...';
@override
String get feedbackImagesLabel => '添加截圖(最多3張)';
@override
String get feedbackAnonymousLabel => '不上傳我的個人信息';
@override
String get feedbackAnonymousHint => '勾選後將不採集您的用戶ID,僅採集設備信息用於問題排查';
@override
String get feedbackSubmit => '提交回饋';
@override
String get feedbackSubmitting => '提交中...';
@override
String get feedbackSuccess => '感謝您的回饋,我們會盡快處理';
@override
String get feedbackContentRequired => '請輸入回饋內容';
@override
String get feedbackContentTooLong => '回饋內容不能超過500字';
@override
String get feedbackTooManyImages => '最多只能上傳3張圖片';
@override
String get feedbackImageTooLarge => '圖片大小不能超過5MB';
}
+19 -1
View File
@@ -487,5 +487,23 @@
"settingsDoNotSellTitle": "个性化广告推荐",
"settingsDoNotSellDescription": "关闭后,我们不会将您的个人信息用于广告推荐",
"settingsDoNotSellEnabled": "已关闭",
"settingsDoNotSellDisabled": "已开启"
"settingsDoNotSellDisabled": "已开启",
"settingsFeedbackTitle": "意见反馈",
"feedbackTitle": "意见反馈",
"feedbackTypeLabel": "反馈类型",
"feedbackTypeBug": "问题反馈",
"feedbackTypeSuggestion": "功能建议",
"feedbackTypeOther": "其他",
"feedbackContentLabel": "反馈内容",
"feedbackContentHint": "请详细描述您的问题或建议...",
"feedbackImagesLabel": "添加截图(最多3张)",
"feedbackAnonymousLabel": "不上传我的个人信息",
"feedbackAnonymousHint": "勾选后将不采集您的用户ID,仅采集设备信息用于问题排查",
"feedbackSubmit": "提交反馈",
"feedbackSubmitting": "提交中...",
"feedbackSuccess": "感谢您的反馈,我们会尽快处理",
"feedbackContentRequired": "请输入反馈内容",
"feedbackContentTooLong": "反馈内容不能超过500字",
"feedbackTooManyImages": "最多只能上传3张图片",
"feedbackImageTooLarge": "图片大小不能超过5MB"
}
+19 -1
View File
@@ -389,5 +389,23 @@
"settingsDoNotSellTitle": "個人化廣告推薦",
"settingsDoNotSellDescription": "關閉後,我們不會將您的個人資訊用於廣告推薦",
"settingsDoNotSellEnabled": "已關閉",
"settingsDoNotSellDisabled": "已開啟"
"settingsDoNotSellDisabled": "已開啟",
"settingsFeedbackTitle": "意見回饋",
"feedbackTitle": "意見回饋",
"feedbackTypeLabel": "回饋類型",
"feedbackTypeBug": "問題回饋",
"feedbackTypeSuggestion": "功能建議",
"feedbackTypeOther": "其他",
"feedbackContentLabel": "回饋內容",
"feedbackContentHint": "請詳細描述您的問題或建議...",
"feedbackImagesLabel": "添加截圖(最多3張)",
"feedbackAnonymousLabel": "不上傳我的個人信息",
"feedbackAnonymousHint": "勾選後將不採集您的用戶ID,僅採集設備信息用於問題排查",
"feedbackSubmit": "提交回饋",
"feedbackSubmitting": "提交中...",
"feedbackSuccess": "感謝您的回饋,我們會盡快處理",
"feedbackContentRequired": "請輸入回饋內容",
"feedbackContentTooLong": "回饋內容不能超過500字",
"feedbackTooManyImages": "最多只能上傳3張圖片",
"feedbackImageTooLarge": "圖片大小不能超過5MB"
}
+2
View File
@@ -47,6 +47,8 @@ dependencies:
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
device_info_plus: ^12.4.0
package_info_plus: ^9.0.1
dev_dependencies:
flutter_test:
@@ -0,0 +1,118 @@
"""create user_feedback table
Revision ID: 20260417_0001
Revises: 20260416_0003
Create Date: 2026-04-17
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB, UUID
revision: str = "20260417_0001"
down_revision: Union[str, Sequence[str], None] = "20260416_0003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"user_feedback",
sa.Column(
"id",
UUID(as_uuid=True),
server_default=sa.text("gen_random_uuid()"),
primary_key=True,
),
sa.Column(
"user_id",
UUID(as_uuid=True),
sa.ForeignKey("auth.users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"feedback_type",
sa.String(20),
nullable=False,
server_default="other",
),
sa.Column("content", sa.Text, nullable=False),
sa.Column(
"images",
JSONB,
nullable=False,
server_default=sa.text("'[]'::jsonb"),
),
sa.Column(
"device_info",
JSONB,
nullable=False,
server_default=sa.text("'{}'::jsonb"),
),
sa.Column("app_version", sa.String(20), nullable=False),
sa.Column("os_version", sa.String(50), nullable=False),
sa.Column(
"status",
sa.String(20),
nullable=False,
server_default="pending",
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index("ix_user_feedback_user_id", "user_feedback", ["user_id"])
op.create_index("ix_user_feedback_created_at", "user_feedback", ["created_at"])
op.create_index("ix_user_feedback_status", "user_feedback", ["status"])
op.execute("COMMENT ON TABLE user_feedback IS '用户反馈表'")
op.execute(
"COMMENT ON COLUMN user_feedback.user_id IS "
"'用户ID,NULL表示匿名(勾选不上传我的个人信息)'"
)
op.execute(
"COMMENT ON COLUMN user_feedback.feedback_type IS "
"'反馈类型: bug/suggestion/other'"
)
op.execute("COMMENT ON COLUMN user_feedback.content IS '反馈内容,最多500字'")
op.execute(
"COMMENT ON COLUMN user_feedback.images IS '图片Storage路径列表,最多3张'"
)
op.execute(
"COMMENT ON COLUMN user_feedback.device_info IS "
"'设备信息JSON,匿名时照样采集(不涉及隐私)'"
)
op.execute(
"COMMENT ON COLUMN user_feedback.status IS '处理状态: pending/processed'"
)
op.execute("ALTER TABLE public.user_feedback ENABLE ROW LEVEL SECURITY")
op.execute("""
CREATE POLICY "Service role full access on user_feedback"
ON public.user_feedback
FOR ALL
TO service_role
USING (true)
WITH CHECK (true)
""")
def downgrade() -> None:
op.execute(
'DROP POLICY IF EXISTS "Service role full access on user_feedback" ON public.user_feedback'
)
op.execute("ALTER TABLE public.user_feedback DISABLE ROW LEVEL SECURITY")
op.drop_table("user_feedback")
+119
View File
@@ -0,0 +1,119 @@
"""手动触发反馈报告生成并推送邮件
用法:
cd /home/qzl/Code/eryao/.worktrees/feat-user-feedback
PYTHONPATH=backend/src uv run python backend/scripts/generate_feedback_report.py [--all] [--no-email]
"""
from __future__ import annotations
import argparse
import asyncio
from datetime import datetime, timedelta
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
from sqlalchemy import select
from core.config.settings import config
from core.db.session import AsyncSessionLocal
from models.user_feedback import UserFeedback
from v1.feedback.report import generate_feedback_report
from v1.feedback.tasks import send_feedback_report_email
async def _fetch_all_feedbacks() -> list[UserFeedback]:
async with AsyncSessionLocal() as session:
stmt = select(UserFeedback).order_by(UserFeedback.created_at.desc())
result = await session.execute(stmt)
return list(result.scalars().all())
async def main():
parser = argparse.ArgumentParser(
description="Generate feedback report and send email"
)
parser.add_argument(
"--all", action="store_true", help="Generate report for all feedbacks"
)
parser.add_argument("--no-email", action="store_true", help="Skip email sending")
args = parser.parse_args()
feedbacks = await _fetch_all_feedbacks()
report_path: Path | None = None
try:
if args.all:
if not feedbacks:
print("No feedbacks found in database")
return
reports_dir = Path(config.runtime.log_dir).parent / "reports"
report_path = await generate_feedback_report(
feedbacks, output_dir=reports_dir
)
print(f"\n=== 全量报告生成完成 ===")
else:
if not feedbacks:
print("No feedbacks found in database")
if not args.no_email:
print("\n发送无反馈通知邮件...")
now = datetime.now()
push_hour = 10
end_time = now.replace(
hour=push_hour, minute=0, second=0, microsecond=0
)
start_time = end_time - timedelta(days=1)
await send_feedback_report_email(
feedbacks=[],
start_time=start_time,
end_time=end_time,
push_hour=push_hour,
report_path=None,
)
print("无反馈通知邮件已发送")
return
reports_dir = Path(config.runtime.log_dir).parent / "reports"
report_path = await generate_feedback_report(
feedbacks, output_dir=reports_dir
)
print(f"\n=== 报告生成完成 ===")
print(f"文件路径: {report_path}")
print(f"文件大小: {report_path.stat().st_size:,} bytes")
if not args.no_email:
now = datetime.now()
push_hour = 10
end_time = now.replace(hour=push_hour, minute=0, second=0, microsecond=0)
start_time = end_time - timedelta(days=1)
print(f"\n发送邮件到: {config.feedback_report.email}")
print(
f"时间范围: {start_time.strftime('%Y-%m-%d %H:%M')} ~ {end_time.strftime('%Y-%m-%d %H:%M')}"
)
print(f"反馈数量: {len(feedbacks)}")
await send_feedback_report_email(
feedbacks=feedbacks,
start_time=start_time,
end_time=end_time,
push_hour=push_hour,
report_path=report_path,
)
print("邮件发送成功")
else:
print("\n跳过邮件发送 (--no-email)")
finally:
if report_path and report_path.exists():
report_path.unlink()
print(f"\n临时文件已清理: {report_path}")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,26 @@
"""手动触发 worker-general 定时任务:生成反馈报告
用法:
cd /home/qzl/Code/eryao/.worktrees/feat-user-feedback
PYTHONPATH=backend/src uv run python backend/scripts/trigger_feedback_report.py
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
from core.taskiq.app import worker_general_broker
from v1.feedback.tasks import generate_daily_feedback_report
def main():
task = generate_daily_feedback_report.kiq()
result = worker_general_broker.wait_result(task, timeout=120)
print(f"Task result: {result.return_value}")
if __name__ == "__main__":
main()
+25
View File
@@ -153,8 +153,13 @@ class StorageSettings(BaseModel):
bucket: str = Field(default="avatars", min_length=3, max_length=63)
max_size_mb: int = Field(default=2, ge=1, le=10)
class FeedbackSettings(BaseModel):
bucket: str = Field(default="feedback-images", min_length=3, max_length=63)
max_size_mb: int = Field(default=5, ge=1, le=20)
attachment: AttachmentSettings = Field(default_factory=AttachmentSettings)
avatar: AvatarSettings = Field(default_factory=AvatarSettings)
feedback: FeedbackSettings = Field(default_factory=FeedbackSettings)
class LlmSettings(BaseModel):
@@ -235,6 +240,22 @@ def _resolve_env_file() -> str:
PROJECT_ROOT = _resolve_project_root()
class FeedbackReportSettings(BaseModel):
email: str = Field(default="support@example.com", description="客服邮箱")
cron: str = Field(default="0 10 * * *", description="报告生成cron表达式")
enabled: bool = Field(default=False, description="是否启用报告推送")
class EmailSettings(BaseModel):
host: str = Field(default="smtp.feishu.cn", description="SMTP 服务器地址")
port: int = Field(default=465, ge=1, le=65535, description="SMTP 端口")
username: str = Field(default="", description="SMTP 用户名")
password: SecretStr = Field(default=SecretStr(""), description="SMTP 密码")
use_ssl: bool = Field(default=True, description="是否使用 SSL")
from_address: str = Field(default="noreply@example.com", description="发件人地址")
from_name: str = Field(default="Eryao Feedback", description="发件人显示名称")
class Settings(BaseSettings):
runtime: RuntimeSettings = RuntimeSettings()
cors: CorsSettings = CorsSettings()
@@ -250,6 +271,10 @@ class Settings(BaseSettings):
taskiq: TaskiqSettings = Field(default_factory=TaskiqSettings)
agent_runtime: AgentRuntimeSettings = Field(default_factory=AgentRuntimeSettings)
points_policy: PointsPolicySettings = Field(default_factory=PointsPolicySettings)
feedback_report: FeedbackReportSettings = Field(
default_factory=FeedbackReportSettings
)
email: EmailSettings = Field(default_factory=EmailSettings)
@computed_field
@property
View File
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from dataclasses import dataclass, field
from email import encoders
from email.mime.base import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import aiosmtplib
from structlog import get_logger
from core.config.settings import config
logger = get_logger("core.email.sender")
@dataclass
class EmailAttachment:
filename: str
content: bytes
content_type: str = "application/octet-stream"
@dataclass
class EmailMessage:
to: str
subject: str
body_html: str
attachments: list[EmailAttachment] = field(default_factory=list)
class EmailSender:
def __init__(self) -> None:
self._settings = config.email
async def send(self, message: EmailMessage) -> bool:
msg = MIMEMultipart()
msg["From"] = f"{self._settings.from_name} <{self._settings.from_address}>"
msg["To"] = message.to
msg["Subject"] = message.subject
msg.attach(MIMEText(message.body_html, "html", "utf-8"))
for attachment in message.attachments:
part = MIMEBase("application", "octet-stream")
part.set_payload(attachment.content)
encoders.encode_base64(part)
part.add_header(
"Content-Disposition",
f"attachment; filename*=UTF-8''{attachment.filename}",
)
msg.attach(part)
try:
await aiosmtplib.send(
msg,
hostname=self._settings.host,
port=self._settings.port,
username=self._settings.username,
password=self._settings.password.get_secret_value(),
use_tls=self._settings.use_ssl,
)
logger.info("Email sent", to=message.to, subject=message.subject)
return True
except Exception as e:
logger.error(
"Failed to send email",
to=message.to,
subject=message.subject,
error=str(e),
)
raise
email_sender = EmailSender()
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
from pathlib import Path
from string import Template
from structlog import get_logger
logger = get_logger("core.email.template_loader")
_TEMPLATES_DIR = Path(__file__).parent / "templates"
def load_template(category: str, name: str) -> Template:
template_path = _TEMPLATES_DIR / category / name
content = template_path.read_text(encoding="utf-8")
return Template(content)
@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; background-color: #f4f5f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f5f7; padding: 32px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #4472C4; padding: 24px 32px;">
<h1 style="margin: 0; color: #ffffff; font-size: 20px; font-weight: 600;">
用户反馈日报
</h1>
</td>
</tr>
<tr>
<td style="padding: 24px 32px;">
<p style="margin: 0 0 16px; color: #333; font-size: 14px; line-height: 1.6;">
您好,
</p>
<p style="margin: 0 0 16px; color: #333; font-size: 14px; line-height: 1.6;">
以下是 <strong>${start_date} ${start_hour}:00</strong>
<strong>${end_date} ${end_hour}:00</strong> 期间的用户反馈汇总。
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0;">
<tr>
<td style="padding: 16px; background-color: #f0f4ff; border-radius: 6px; text-align: center; width: 33%;">
<p style="margin: 0; font-size: 28px; font-weight: 700; color: #4472C4;">${total_count}</p>
<p style="margin: 4px 0 0; font-size: 12px; color: #666;">反馈总数</p>
</td>
<td style="padding: 16px; background-color: #fff3e0; border-radius: 6px; text-align: center; width: 33%;">
<p style="margin: 0; font-size: 28px; font-weight: 700; color: #ed6c02;">${bug_count}</p>
<p style="margin: 4px 0 0; font-size: 12px; color: #666;">问题反馈</p>
</td>
<td style="padding: 16px; background-color: #e8f5e9; border-radius: 6px; text-align: center; width: 33%;">
<p style="margin: 0; font-size: 28px; font-weight: 700; color: #2e7d32;">${suggestion_count}</p>
<p style="margin: 4px 0 0; font-size: 12px; color: #666;">功能建议</p>
</td>
</tr>
</table>
<p style="margin: 16px 0 0; color: #666; font-size: 13px; line-height: 1.6;">
详细反馈内容及截图请查看附件中的 xlsx 报告。
</p>
</td>
</tr>
<tr>
<td style="padding: 16px 32px; border-top: 1px solid #eee; background-color: #fafafa;">
<p style="margin: 0; font-size: 11px; color: #999; text-align: center;">
此邮件由 Eryao 反馈系统自动发送,请勿直接回复。<br>
报告生成时间:${generated_at}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -0,0 +1,62 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; background-color: #f4f5f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f5f7; padding: 32px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<tr>
<td style="background-color: #95a5a6; padding: 24px 32px;">
<h1 style="margin: 0; color: #ffffff; font-size: 20px; font-weight: 600;">
用户反馈日报
</h1>
</td>
</tr>
<tr>
<td style="padding: 32px;">
<p style="margin: 0 0 16px; color: #333; font-size: 14px; line-height: 1.6;">
您好,
</p>
<p style="margin: 0 0 16px; color: #333; font-size: 14px; line-height: 1.6;">
<strong>${start_date} ${start_hour}:00</strong>
<strong>${end_date} ${end_hour}:00</strong> 期间暂无用户反馈。
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0;">
<tr>
<td style="padding: 24px; background-color: #f8f9fa; border-radius: 6px; text-align: center;">
<p style="margin: 0; font-size: 32px;">📭</p>
<p style="margin: 8px 0 0; font-size: 14px; color: #999;">
今日无反馈数据
</p>
</td>
</tr>
</table>
<p style="margin: 16px 0 0; color: #999; font-size: 12px; line-height: 1.6;">
如有反馈,系统将在下一个报告周期自动推送。
</p>
</td>
</tr>
<tr>
<td style="padding: 16px 32px; border-top: 1px solid #eee; background-color: #fafafa;">
<p style="margin: 0; font-size: 11px; color: #999; text-align: center;">
此邮件由 Eryao 反馈系统自动发送,请勿直接回复。<br>
报告生成时间:${generated_at}
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import uuid
from sqlalchemy import Index, String, Text, text
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, TimestampMixin
class UserFeedback(TimestampMixin, Base):
__tablename__ = "user_feedback"
__table_args__ = (
Index("ix_user_feedback_user_id", "user_id"),
Index("ix_user_feedback_created_at", "created_at"),
Index("ix_user_feedback_status", "status"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
server_default=text("gen_random_uuid()"),
primary_key=True,
)
user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
nullable=True,
)
feedback_type: Mapped[str] = mapped_column(
String(20), nullable=False, server_default="other"
)
content: Mapped[str] = mapped_column(Text, nullable=False)
images: Mapped[list[str]] = mapped_column(
JSONB, nullable=False, server_default=text("'[]'::jsonb"), default=list
)
device_info: Mapped[dict] = mapped_column(
JSONB, nullable=False, server_default=text("'{}'::jsonb"), default=dict
)
app_version: Mapped[str] = mapped_column(String(20), nullable=False)
os_version: Mapped[str] = mapped_column(String(50), nullable=False)
status: Mapped[str] = mapped_column(
String(20), nullable=False, server_default="pending"
)
+2
View File
@@ -110,6 +110,7 @@ class SupabaseService(BaseServiceProvider):
buckets = [
(config.storage.attachment.bucket, False),
(config.storage.avatar.bucket, True),
(config.storage.feedback.bucket, False),
]
def _check_and_create() -> None:
@@ -170,6 +171,7 @@ class SupabaseService(BaseServiceProvider):
allowed_buckets = {
config.storage.attachment.bucket,
config.storage.avatar.bucket,
config.storage.feedback.bucket,
}
if bucket not in allowed_buckets:
raise RuntimeError("Invalid storage bucket")
View File
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import asyncio
from typing import Annotated
from uuid import UUID
from fastapi import Depends, Header
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth.models import CurrentUser
from core.db import get_db
from services.base.supabase import supabase_service
from v1.feedback.repository import FeedbackRepository
from v1.feedback.service import FeedbackService
async def get_optional_user(
authorization: str | None = Header(default=None),
) -> CurrentUser | None:
if not authorization:
return None
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not token:
return None
try:
client = supabase_service.get_client()
response = await asyncio.to_thread(client.auth.get_user, token)
user = getattr(response, "user", None)
user_id = getattr(user, "id", None)
if not isinstance(user_id, str) or not user_id:
return None
return CurrentUser(
id=UUID(user_id),
email=getattr(user, "email", None),
role=getattr(user, "role", None),
)
except Exception:
return None
def get_feedback_service(
session: Annotated[AsyncSession, Depends(get_db)],
) -> FeedbackService:
return FeedbackService(
repository=FeedbackRepository(session=session),
storage=supabase_service,
)
+220
View File
@@ -0,0 +1,220 @@
from __future__ import annotations
from datetime import datetime, timezone
from io import BytesIO
from pathlib import Path
from tempfile import gettempdir
from openpyxl import Workbook
from openpyxl.drawing.image import Image as XLImage
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from structlog import get_logger
from core.config.settings import config
from models.user_feedback import UserFeedback
from services.base.supabase import supabase_service
logger = get_logger("v1.feedback.report")
IMAGE_PREVIEW_WIDTH_PX = 100
IMAGE_PREVIEW_HEIGHT_PX = 100
IMAGE_COL_WIDTH = 15
IMAGE_ROW_HEIGHT = 80
async def download_image_from_storage(storage_path: str) -> bytes:
bucket_name = config.storage.feedback.bucket
try:
return await supabase_service.download_bytes(
bucket=bucket_name, path=storage_path
)
except Exception as e:
logger.warning("Failed to download image", path=storage_path, error=str(e))
raise
def _embed_image_in_cell(ws, img_data: bytes, row: int, col: int) -> bool:
try:
img_buffer = BytesIO(img_data)
xl_img = XLImage(img_buffer)
xl_img.width = IMAGE_PREVIEW_WIDTH_PX
xl_img.height = IMAGE_PREVIEW_HEIGHT_PX
xl_img.anchor = f"{get_column_letter(col)}{row}"
ws.add_image(xl_img)
return True
except Exception as e:
logger.warning("Failed to embed image", row=row, col=col, error=str(e))
return False
async def generate_feedback_report(
feedbacks: list[UserFeedback],
*,
output_dir: Path | None = None,
filename_prefix: str = "feedback_report",
) -> Path:
wb = Workbook()
ws = wb.active
assert ws is not None
ws.title = "用户反馈"
header_font = Font(bold=True, color="FFFFFF", size=11)
header_fill = PatternFill(
start_color="4472C4", end_color="4472C4", fill_type="solid"
)
header_alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
thin_border = Border(
left=Side(style="thin", color="D9D9D9"),
right=Side(style="thin", color="D9D9D9"),
top=Side(style="thin", color="D9D9D9"),
bottom=Side(style="thin", color="D9D9D9"),
)
center_alignment = Alignment(horizontal="center", vertical="center")
headers = [
"序号",
"提交时间",
"反馈类型",
"用户身份",
"设备信息",
"App版本",
"系统版本",
"反馈内容",
"图片数量",
"状态",
"截图1",
"截图2",
"截图3",
]
col_widths = [
6,
18,
12,
12,
25,
10,
14,
50,
10,
10,
IMAGE_COL_WIDTH,
IMAGE_COL_WIDTH,
IMAGE_COL_WIDTH,
]
for col, (header, width) in enumerate(zip(headers, col_widths), start=1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = thin_border
ws.column_dimensions[get_column_letter(col)].width = width
ws.row_dimensions[1].height = 25
ws.freeze_panes = "A2"
type_colors = {
"bug": PatternFill(start_color="FCE4D6", end_color="FCE4D6", fill_type="solid"),
"suggestion": PatternFill(
start_color="E2EFDA", end_color="E2EFDA", fill_type="solid"
),
"other": PatternFill(
start_color="FFF2CC", end_color="FFF2CC", fill_type="solid"
),
}
for row_idx, fb in enumerate(feedbacks, start=2):
ws.cell(row=row_idx, column=1, value=row_idx - 1).alignment = center_alignment
ws.cell(row=row_idx, column=2, value=fb.created_at.strftime("%Y-%m-%d %H:%M"))
type_cell = ws.cell(row=row_idx, column=3, value=fb.feedback_type)
type_cell.alignment = center_alignment
type_cell.fill = type_colors.get(fb.feedback_type, PatternFill())
if fb.user_id:
ws.cell(row=row_idx, column=4, value=f"用户:{str(fb.user_id)[:8]}...")
else:
ws.cell(row=row_idx, column=4, value="匿名").alignment = center_alignment
device_info = fb.device_info or {}
ws.cell(
row=row_idx,
column=5,
value=f"{device_info.get('platform', '-')} / {device_info.get('model', '-')}",
)
ws.cell(
row=row_idx, column=6, value=fb.app_version
).alignment = center_alignment
ws.cell(row=row_idx, column=7, value=fb.os_version)
content_cell = ws.cell(row=row_idx, column=8, value=fb.content)
content_cell.alignment = Alignment(vertical="top", wrap_text=True)
ws.cell(
row=row_idx, column=9, value=len(fb.images) if fb.images else 0
).alignment = center_alignment
status_cell = ws.cell(row=row_idx, column=10, value=fb.status)
status_cell.alignment = center_alignment
if fb.status == "pending":
status_cell.fill = PatternFill(
start_color="FFF2CC", end_color="FFF2CC", fill_type="solid"
)
elif fb.status == "processed":
status_cell.fill = PatternFill(
start_color="C6EFCE", end_color="C6EFCE", fill_type="solid"
)
ws.row_dimensions[row_idx].height = IMAGE_ROW_HEIGHT
images = fb.images or []
for img_idx in range(3):
img_col = 11 + img_idx
img_cell = ws.cell(row=row_idx, column=img_col, value="")
img_cell.border = thin_border
if img_idx < len(images):
try:
img_data = await download_image_from_storage(images[img_idx])
success = _embed_image_in_cell(ws, img_data, row_idx, img_col)
if not success:
img_cell.value = "加载失败"
img_cell.alignment = center_alignment
except Exception:
img_cell.value = "加载失败"
img_cell.alignment = center_alignment
else:
img_cell.value = "-"
img_cell.alignment = center_alignment
for col in range(1, 11):
ws.cell(row=row_idx, column=col).border = thin_border
ws.auto_filter.ref = (
f"A1:{get_column_letter(len(headers))}{max(2, len(feedbacks) + 1)}"
)
if output_dir is None:
output_dir = Path(gettempdir())
output_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
report_path = output_dir / f"{filename_prefix}_{timestamp}.xlsx"
wb.save(report_path)
logger.info(
"Feedback report generated", path=str(report_path), count=len(feedbacks)
)
return report_path
async def cleanup_report_file(report_path: Path) -> None:
try:
if report_path.exists():
report_path.unlink()
logger.info("Report file cleaned up", path=str(report_path))
except Exception as e:
logger.warning(
"Failed to cleanup report file", path=str(report_path), error=str(e)
)
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from models.user_feedback import UserFeedback
@dataclass
class FeedbackRepository:
session: AsyncSession
async def create_feedback(
self,
*,
user_id: UUID | None,
feedback_type: str,
content: str,
images: list[str],
device_info: dict,
app_version: str,
os_version: str,
) -> UserFeedback:
feedback = UserFeedback(
user_id=user_id,
feedback_type=feedback_type,
content=content,
images=images,
device_info=device_info,
app_version=app_version,
os_version=os_version,
)
self.session.add(feedback)
await self.session.flush()
return feedback
async def save(self) -> None:
await self.session.commit()
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import json
from typing import Annotated
from fastapi import APIRouter, Depends, File, Form, UploadFile
from core.auth.models import CurrentUser
from core.http.errors import ApiProblemError, problem_payload
from v1.feedback.dependencies import get_feedback_service, get_optional_user
from v1.feedback.schemas import FeedbackCreateResponse
from v1.feedback.service import FeedbackService
router = APIRouter(prefix="/feedback", tags=["feedback"])
@router.post("", response_model=FeedbackCreateResponse, status_code=201)
async def create_feedback(
feedback_type: Annotated[str, Form(...)],
content: Annotated[str, Form(...)],
device_info: Annotated[str, Form(...)],
app_version: Annotated[str, Form(...)],
os_version: Annotated[str, Form(...)],
images: list[UploadFile] = File(default=[]),
user: CurrentUser | None = Depends(get_optional_user),
service: FeedbackService = Depends(get_feedback_service),
) -> FeedbackCreateResponse:
if len(images) > 3:
raise ApiProblemError(
status_code=400,
detail=problem_payload(
code="FEEDBACK_TOO_MANY_IMAGES",
detail="Maximum 3 images allowed",
),
)
try:
device_info_dict = json.loads(device_info)
except (json.JSONDecodeError, TypeError) as exc:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="REQUEST_VALIDATION_ERROR",
detail="Invalid device_info JSON",
),
) from exc
user_id = user.id if isinstance(user, CurrentUser) else None
return await service.submit_feedback(
feedback_type=feedback_type,
content=content,
device_info=device_info_dict,
app_version=app_version,
os_version=os_version,
images=images,
user_id=user_id,
)
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict
FeedbackType = Literal["bug", "suggestion", "other"]
FeedbackStatus = Literal["pending", "processed"]
class FeedbackCreateResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str
created_at: str
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from uuid import UUID
from fastapi import UploadFile
from structlog import get_logger
from core.config.settings import config
from core.http.errors import ApiProblemError, problem_payload
from services.base.supabase import SupabaseService
from v1.feedback.repository import FeedbackRepository
from v1.feedback.schemas import FeedbackCreateResponse
logger = get_logger("v1.feedback.service")
_MAX_IMAGES = 3
_ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png"}
_ALLOWED_FEEDBACK_TYPES = {"bug", "suggestion", "other"}
@dataclass
class FeedbackService:
repository: FeedbackRepository
storage: SupabaseService
async def submit_feedback(
self,
*,
feedback_type: str,
content: str,
device_info: dict,
app_version: str,
os_version: str,
images: list[UploadFile],
user_id: UUID | None,
) -> FeedbackCreateResponse:
self._validate_feedback_type(feedback_type)
self._validate_content(content)
self._validate_images(images)
image_paths: list[str] = []
if images:
image_paths = await self._upload_images(images)
feedback = await self.repository.create_feedback(
user_id=user_id,
feedback_type=feedback_type,
content=content,
images=image_paths,
device_info=device_info,
app_version=app_version,
os_version=os_version,
)
await self.repository.save()
logger.info(
"Feedback submitted",
feedback_id=str(feedback.id),
user_id=str(user_id) if user_id else "anonymous",
image_count=len(image_paths),
)
return FeedbackCreateResponse(
id=str(feedback.id),
created_at=feedback.created_at.isoformat(),
)
def _validate_feedback_type(self, feedback_type: str) -> None:
if feedback_type not in _ALLOWED_FEEDBACK_TYPES:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="REQUEST_VALIDATION_ERROR",
detail=f"Invalid feedback_type: {feedback_type}",
),
)
def _validate_content(self, content: str) -> None:
stripped = content.strip()
if not stripped:
raise ApiProblemError(
status_code=400,
detail=problem_payload(
code="FEEDBACK_CONTENT_EMPTY",
detail="Feedback content must not be empty",
),
)
if len(stripped) > 500:
raise ApiProblemError(
status_code=400,
detail=problem_payload(
code="FEEDBACK_CONTENT_TOO_LONG",
detail="Feedback content exceeds 500 characters",
),
)
def _validate_images(self, images: list[UploadFile]) -> None:
if len(images) > _MAX_IMAGES:
raise ApiProblemError(
status_code=400,
detail=problem_payload(
code="FEEDBACK_TOO_MANY_IMAGES",
detail="Maximum 3 images allowed",
),
)
for image in images:
content_type = (image.content_type or "").lower().strip()
if content_type and content_type not in _ALLOWED_CONTENT_TYPES:
raise ApiProblemError(
status_code=400,
detail=problem_payload(
code="FEEDBACK_INVALID_IMAGE_TYPE",
detail=f"Unsupported image type: {content_type}",
),
)
async def _upload_images(self, images: list[UploadFile]) -> list[str]:
bucket = config.storage.feedback.bucket
max_bytes = config.storage.feedback.max_size_mb * 1024 * 1024
timestamp_prefix = datetime.now(timezone.utc).strftime("%Y-%m-%d")
paths: list[str] = []
for i, image in enumerate(images):
content = await image.read()
if len(content) > max_bytes:
raise ApiProblemError(
status_code=400,
detail=problem_payload(
code="FEEDBACK_IMAGE_TOO_LARGE",
detail=f"Image too large: {image.filename}",
),
)
content_type = image.content_type or "image/jpeg"
ext = "jpg" if content_type == "image/jpeg" else "png"
storage_path = (
f"{timestamp_prefix}/{datetime.now(timezone.utc).timestamp()}_{i}.{ext}"
)
try:
await self.storage.upload_bytes(
bucket=bucket,
path=storage_path,
content=content,
content_type=content_type,
)
except Exception as exc:
logger.exception(
"Feedback image upload failed",
path=storage_path,
filename=image.filename,
)
raise ApiProblemError(
status_code=500,
detail=problem_payload(
code="FEEDBACK_SUBMIT_FAILED",
detail="Failed to upload feedback images",
),
) from exc
paths.append(storage_path)
return paths
+226
View File
@@ -0,0 +1,226 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pathlib import Path
from sqlalchemy import select
from structlog import get_logger
from taskiq_redis import RedisScheduleSource
from core.config.settings import config
from core.db.session import AsyncSessionLocal
from core.email.sender import EmailAttachment, EmailMessage, EmailSender
from core.email.template_loader import load_template
from core.taskiq.app import worker_general_broker
from models.user_feedback import UserFeedback
from v1.feedback.report import generate_feedback_report
logger = get_logger("v1.feedback.tasks")
async def _fetch_pending_feedbacks_by_time_range(
start_time: datetime, end_time: datetime
) -> list[UserFeedback]:
async with AsyncSessionLocal() as session:
stmt = (
select(UserFeedback)
.where(UserFeedback.created_at >= start_time)
.where(UserFeedback.created_at < end_time)
.where(UserFeedback.status == "pending")
.order_by(UserFeedback.created_at.desc())
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def _mark_feedbacks_processed(feedback_ids: list) -> None:
if not feedback_ids:
return
async with AsyncSessionLocal() as session:
stmt = select(UserFeedback).where(UserFeedback.id.in_(feedback_ids))
result = await session.execute(stmt)
for fb in result.scalars().all():
fb.status = "processed"
fb.updated_at = datetime.now(timezone.utc)
await session.commit()
logger.info("Feedbacks marked as processed", count=len(feedback_ids))
def _build_report_email_html(
feedbacks: list[UserFeedback],
start_time: datetime,
end_time: datetime,
push_hour: int,
) -> str:
template = load_template("feedback", "daily_report.html")
return template.substitute(
start_date=start_time.strftime("%Y-%m-%d"),
start_hour=str(push_hour),
end_date=end_time.strftime("%Y-%m-%d"),
end_hour=str(push_hour),
total_count=len(feedbacks),
bug_count=sum(1 for fb in feedbacks if fb.feedback_type == "bug"),
suggestion_count=sum(1 for fb in feedbacks if fb.feedback_type == "suggestion"),
generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
)
def _build_no_feedback_email_html(
start_time: datetime,
end_time: datetime,
push_hour: int,
) -> str:
template = load_template("feedback", "no_feedback.html")
return template.substitute(
start_date=start_time.strftime("%Y-%m-%d"),
start_hour=str(push_hour),
end_date=end_time.strftime("%Y-%m-%d"),
end_hour=str(push_hour),
generated_at=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
)
async def _send_feedback_email(
feedbacks: list[UserFeedback],
start_time: datetime,
end_time: datetime,
push_hour: int,
report_path: Path | None = None,
) -> bool:
sender = EmailSender()
if feedbacks:
body_html = _build_report_email_html(feedbacks, start_time, end_time, push_hour)
subject = f"用户反馈日报 - {start_time.strftime('%Y-%m-%d')}"
else:
body_html = _build_no_feedback_email_html(start_time, end_time, push_hour)
subject = f"用户反馈日报(无反馈)- {start_time.strftime('%Y-%m-%d')}"
attachments: list[EmailAttachment] = []
if report_path is not None and report_path.exists():
attachments.append(
EmailAttachment(
filename=report_path.name,
content=report_path.read_bytes(),
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
)
message = EmailMessage(
to=config.feedback_report.email,
subject=subject,
body_html=body_html,
attachments=attachments,
)
await sender.send(message)
logger.info(
"Feedback report email sent",
to=config.feedback_report.email,
has_feedback=len(feedbacks) > 0,
has_attachment=len(attachments) > 0,
)
return True
# type: ignore reportArgumentType for taskiq decorator
@worker_general_broker.on_event("startup") # type: ignore[arg-type]
async def _register_feedback_report_schedule() -> None:
if not config.feedback_report.enabled:
logger.info("Feedback report scheduling disabled")
return
schedule_source = RedisScheduleSource(
url=config.taskiq_broker_url,
prefix="schedule:feedback",
)
await schedule_source.startup()
await generate_daily_feedback_report.schedule_by_cron(
source=schedule_source,
cron=config.feedback_report.cron,
)
logger.info(
"Feedback report cron registered",
cron=config.feedback_report.cron,
)
@worker_general_broker.task(task_name="tasks.feedback.generate_daily_report")
async def generate_daily_feedback_report() -> str | None:
if not config.feedback_report.enabled:
logger.info("Feedback report is disabled, skipping")
return None
now = datetime.now(timezone.utc)
push_hour = 10
end_time = now.replace(hour=push_hour, minute=0, second=0, microsecond=0)
start_time = end_time - timedelta(days=1)
feedbacks = await _fetch_pending_feedbacks_by_time_range(start_time, end_time)
logger.info(
"Feedback query result",
count=len(feedbacks),
start=start_time,
end=end_time,
)
report_path: Path | None = None
try:
if feedbacks:
reports_dir = Path(config.runtime.log_dir).parent / "reports"
report_path = await generate_feedback_report(
feedbacks, output_dir=reports_dir
)
logger.info("Report generated", path=str(report_path))
await _mark_feedbacks_processed([fb.id for fb in feedbacks])
await _send_feedback_email(
feedbacks=feedbacks,
start_time=start_time,
end_time=end_time,
push_hour=push_hour,
report_path=report_path,
)
finally:
if report_path and report_path.exists():
report_path.unlink()
logger.info("Report file cleaned up", path=str(report_path))
return str(report_path) if report_path else None
async def generate_all_feedback_report() -> Path:
async with AsyncSessionLocal() as session:
stmt = select(UserFeedback).order_by(UserFeedback.created_at.desc())
result = await session.execute(stmt)
feedbacks = list(result.scalars().all())
if not feedbacks:
raise ValueError("No feedbacks to report")
logger.info("Generating all feedback report", count=len(feedbacks))
reports_dir = Path(config.runtime.log_dir).parent / "reports"
report_path = await generate_feedback_report(feedbacks, output_dir=reports_dir)
await _mark_feedbacks_processed([fb.id for fb in feedbacks])
return report_path
async def send_feedback_report_email(
feedbacks: list[UserFeedback],
start_time: datetime,
end_time: datetime,
push_hour: int,
report_path: Path | None = None,
) -> bool:
return await _send_feedback_email(
feedbacks=feedbacks,
start_time=start_time,
end_time=end_time,
push_hour=push_hour,
report_path=report_path,
)
+2
View File
@@ -4,6 +4,7 @@ from fastapi import APIRouter
from v1.agent.router import router as agent_router
from v1.auth.router import router as auth_router
from v1.feedback.router import router as feedback_router
from v1.invite.router import router as invite_router
from v1.notifications.router import router as notifications_router
from v1.points.router import router as points_router
@@ -13,6 +14,7 @@ from v1.users.router import router as users_router
router = APIRouter(prefix="/api/v1")
router.include_router(auth_router)
router.include_router(agent_router)
router.include_router(feedback_router)
router.include_router(invite_router)
router.include_router(notifications_router)
router.include_router(points_router)
@@ -0,0 +1,43 @@
from __future__ import annotations
import os
import time
import pytest
def pytest_configure(config): # noqa: ARG001
config.addinivalue_line(
"markers", "integration: integration test requiring live backend"
)
def pytest_collection_modifyitems(items):
for item in items:
if "integration" in item.nodeid:
item.add_marker(pytest.mark.integration)
@pytest.fixture(scope="session")
def api_base_url() -> str:
return os.environ.get("ERYAO_TEST_BASE_URL", "http://localhost:5775")
@pytest.fixture
def unique_test_email() -> str:
base_email = os.environ.get("ERYAO_TEST__EMAIL", "test@example.com").strip().lower()
if "@" in base_email:
name, domain = base_email.split("@", 1)
else:
name, domain = base_email, "example.com"
return f"{name}+fb{int(time.time() * 1000)}@{domain}"
@pytest.fixture
def test_verify_code() -> str:
return os.environ.get("ERYAO_TEST__CODE", "123456")
@pytest.fixture
def test_identity(unique_test_email: str, test_verify_code: str) -> dict[str, str]:
return {"email": unique_test_email, "code": test_verify_code}
@@ -0,0 +1,159 @@
from __future__ import annotations
from collections.abc import AsyncIterator
import httpx
import pytest
import json
@pytest.fixture
async def feedback_client(api_base_url: str) -> AsyncIterator[httpx.AsyncClient]:
async with httpx.AsyncClient(base_url=api_base_url, timeout=30.0) as client:
try:
health = await client.get("/health")
if health.status_code != 200:
pytest.skip(f"API not ready: /health={health.status_code}")
except Exception as exc:
pytest.skip(f"API unavailable: {exc}")
yield client
@pytest.fixture
async def authed_feedback_client(
feedback_client: httpx.AsyncClient,
test_identity: dict[str, str],
) -> httpx.AsyncClient:
otp_response = await feedback_client.post(
"/api/v1/auth/otp",
json={"email": test_identity["email"]},
)
if otp_response.status_code not in (200, 204):
pytest.skip(f"OTP request failed: {otp_response.status_code}")
verify_response = await feedback_client.post(
"/api/v1/auth/verify",
json={
"email": test_identity["email"],
"code": test_identity["code"],
},
)
if verify_response.status_code != 200:
pytest.skip(f"Auth verify failed: {verify_response.status_code}")
token = verify_response.json().get("access_token") or verify_response.json().get(
"session", {}
).get("access_token")
if not token:
pytest.skip("No access token in auth response")
feedback_client.headers["Authorization"] = f"Bearer {token}"
return feedback_client
class TestFeedbackSubmitAnonymous:
@pytest.mark.asyncio
async def test_submit_feedback_anonymous_success(
self, feedback_client: httpx.AsyncClient
):
response = await feedback_client.post(
"/api/v1/feedback",
data={
"feedback_type": "bug",
"content": "App crashes when opening settings",
"device_info": json.dumps({"platform": "ios", "model": "iPhone 15"}),
"app_version": "1.0.0",
"os_version": "iOS 17.0",
},
)
assert response.status_code == 201
body = response.json()
assert "id" in body
assert "created_at" in body
@pytest.mark.asyncio
async def test_submit_feedback_with_auth(
self, authed_feedback_client: httpx.AsyncClient
):
response = await authed_feedback_client.post(
"/api/v1/feedback",
data={
"feedback_type": "suggestion",
"content": "Please add dark mode",
"device_info": json.dumps({"platform": "android", "model": "Pixel 8"}),
"app_version": "1.0.0",
"os_version": "Android 14",
},
)
assert response.status_code == 201
body = response.json()
assert "id" in body
assert "created_at" in body
@pytest.mark.asyncio
async def test_submit_feedback_content_empty(
self, feedback_client: httpx.AsyncClient
):
response = await feedback_client.post(
"/api/v1/feedback",
data={
"feedback_type": "bug",
"content": "",
"device_info": json.dumps({"platform": "ios", "model": "iPhone 15"}),
"app_version": "1.0.0",
"os_version": "iOS 17.0",
},
)
assert response.status_code == 400
assert response.json().get("code") == "FEEDBACK_CONTENT_EMPTY"
@pytest.mark.asyncio
async def test_submit_feedback_content_too_long(
self, feedback_client: httpx.AsyncClient
):
response = await feedback_client.post(
"/api/v1/feedback",
data={
"feedback_type": "bug",
"content": "x" * 501,
"device_info": json.dumps({"platform": "ios", "model": "iPhone 15"}),
"app_version": "1.0.0",
"os_version": "iOS 17.0",
},
)
assert response.status_code == 400
assert response.json().get("code") == "FEEDBACK_CONTENT_TOO_LONG"
@pytest.mark.asyncio
async def test_submit_feedback_invalid_device_info(
self, feedback_client: httpx.AsyncClient
):
response = await feedback_client.post(
"/api/v1/feedback",
data={
"feedback_type": "bug",
"content": "Test content",
"device_info": "not-json",
"app_version": "1.0.0",
"os_version": "iOS 17.0",
},
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_submit_feedback_with_image(self, feedback_client: httpx.AsyncClient):
fake_image = b"\xff\xd8\xff\xe0" + b"\x00" * 100
response = await feedback_client.post(
"/api/v1/feedback",
data={
"feedback_type": "bug",
"content": "Screenshot of the issue",
"device_info": json.dumps({"platform": "ios", "model": "iPhone 15"}),
"app_version": "1.0.0",
"os_version": "iOS 17.0",
},
files=[("images", ("screenshot.jpg", fake_image, "image/jpeg"))],
)
assert response.status_code == 201
body = response.json()
assert "id" in body
+349
View File
@@ -0,0 +1,349 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
import pytest
from core.http.errors import ApiProblemError
from v1.feedback.schemas import FeedbackCreateResponse
class TestFeedbackCreateResponse:
def test_valid_response(self):
resp = FeedbackCreateResponse(
id=str(uuid4()),
created_at="2026-04-17T10:30:00Z",
)
assert isinstance(resp.id, str)
assert resp.created_at == "2026-04-17T10:30:00Z"
def test_extra_fields_forbidden(self):
with pytest.raises(Exception):
FeedbackCreateResponse(
id=str(uuid4()),
created_at="2026-04-17T10:30:00Z",
unexpected_field="value",
)
class _FakeUserFeedback:
def __init__(
self,
*,
id: UUID,
user_id: UUID | None,
feedback_type: str,
content: str,
images: list[str],
device_info: dict,
app_version: str,
os_version: str,
status: str = "pending",
) -> None:
self.id = id
self.user_id = user_id
self.feedback_type = feedback_type
self.content = content
self.images = images
self.device_info = device_info
self.app_version = app_version
self.os_version = os_version
self.status = status
self.created_at = datetime.now()
self.updated_at = datetime.now()
class _FakeFeedbackRepository:
def __init__(self) -> None:
self._records: list[_FakeUserFeedback] = []
self._committed = False
async def create_feedback(
self,
*,
user_id: UUID | None,
feedback_type: str,
content: str,
images: list[str],
device_info: dict,
app_version: str,
os_version: str,
) -> _FakeUserFeedback:
record = _FakeUserFeedback(
id=uuid4(),
user_id=user_id,
feedback_type=feedback_type,
content=content,
images=images,
device_info=device_info,
app_version=app_version,
os_version=os_version,
)
self._records.append(record)
return record
async def save(self) -> None:
self._committed = True
class _FakeUploadFile:
def __init__(
self,
*,
filename: str = "test.jpg",
content_type: str = "image/jpeg",
content: bytes = b"fake-image-data",
) -> None:
self.filename = filename
self.content_type = content_type
self._content = content
self._read = False
async def read(self) -> bytes:
self._read = True
return self._content
class _FakeStorage:
def __init__(self) -> None:
self.uploaded: list[dict] = []
async def upload_bytes(
self,
*,
bucket: str,
path: str,
content: bytes,
content_type: str,
) -> str:
self.uploaded.append(
{
"bucket": bucket,
"path": path,
"content_type": content_type,
"size": len(content),
}
)
return path
@pytest.fixture
def fake_repo() -> _FakeFeedbackRepository:
return _FakeFeedbackRepository()
@pytest.fixture
def fake_storage() -> _FakeStorage:
return _FakeStorage()
class TestFeedbackServiceValidation:
@pytest.mark.asyncio
async def test_submit_feedback_success_no_images(
self, fake_repo: _FakeFeedbackRepository, fake_storage: _FakeStorage
):
from v1.feedback.service import FeedbackService
service = FeedbackService(repository=fake_repo, storage=fake_storage)
result = await service.submit_feedback(
feedback_type="bug",
content="App crashes on launch",
device_info={"platform": "ios", "model": "iPhone 15"},
app_version="1.0.0",
os_version="iOS 17.0",
images=[],
user_id=uuid4(),
)
assert isinstance(result, FeedbackCreateResponse)
assert result.id
assert result.created_at
assert len(fake_repo._records) == 1
assert fake_repo._records[0].feedback_type == "bug"
assert fake_repo._records[0].images == []
@pytest.mark.asyncio
async def test_submit_feedback_anonymous(
self, fake_repo: _FakeFeedbackRepository, fake_storage: _FakeStorage
):
from v1.feedback.service import FeedbackService
service = FeedbackService(repository=fake_repo, storage=fake_storage)
await service.submit_feedback(
feedback_type="suggestion",
content="Add dark mode",
device_info={"platform": "android", "model": "Pixel 8"},
app_version="1.0.0",
os_version="Android 14",
images=[],
user_id=None,
)
assert fake_repo._records[0].user_id is None
@pytest.mark.asyncio
async def test_submit_feedback_with_images(
self, fake_repo: _FakeFeedbackRepository, fake_storage: _FakeStorage
):
from v1.feedback.service import FeedbackService
service = FeedbackService(repository=fake_repo, storage=fake_storage)
images = [
_FakeUploadFile(filename="screenshot1.jpg", content_type="image/jpeg"),
_FakeUploadFile(filename="screenshot2.png", content_type="image/png"),
]
await service.submit_feedback(
feedback_type="bug",
content="UI glitch",
device_info={"platform": "ios", "model": "iPhone 15"},
app_version="1.0.0",
os_version="iOS 17.0",
images=images, # type: ignore[arg-type]
user_id=uuid4(),
)
assert len(fake_storage.uploaded) == 2
assert len(fake_repo._records[0].images) == 2
@pytest.mark.asyncio
async def test_content_empty_raises(
self, fake_repo: _FakeFeedbackRepository, fake_storage: _FakeStorage
):
from v1.feedback.service import FeedbackService
service = FeedbackService(repository=fake_repo, storage=fake_storage)
with pytest.raises(ApiProblemError) as exc_info:
await service.submit_feedback(
feedback_type="bug",
content=" ",
device_info={"platform": "ios", "model": "iPhone 15"},
app_version="1.0.0",
os_version="iOS 17.0",
images=[],
user_id=None,
)
assert exc_info.value.code == "FEEDBACK_CONTENT_EMPTY"
@pytest.mark.asyncio
async def test_content_too_long_raises(
self, fake_repo: _FakeFeedbackRepository, fake_storage: _FakeStorage
):
from v1.feedback.service import FeedbackService
service = FeedbackService(repository=fake_repo, storage=fake_storage)
with pytest.raises(ApiProblemError) as exc_info:
await service.submit_feedback(
feedback_type="bug",
content="x" * 501,
device_info={"platform": "ios", "model": "iPhone 15"},
app_version="1.0.0",
os_version="iOS 17.0",
images=[],
user_id=None,
)
assert exc_info.value.code == "FEEDBACK_CONTENT_TOO_LONG"
@pytest.mark.asyncio
async def test_too_many_images_raises(
self, fake_repo: _FakeFeedbackRepository, fake_storage: _FakeStorage
):
from v1.feedback.service import FeedbackService
service = FeedbackService(repository=fake_repo, storage=fake_storage)
images = [_FakeUploadFile() for _ in range(4)]
with pytest.raises(ApiProblemError) as exc_info:
await service.submit_feedback(
feedback_type="bug",
content="Test",
device_info={"platform": "ios", "model": "iPhone 15"},
app_version="1.0.0",
os_version="iOS 17.0",
images=images, # type: ignore[arg-type]
user_id=None,
)
assert exc_info.value.code == "FEEDBACK_TOO_MANY_IMAGES"
@pytest.mark.asyncio
async def test_invalid_image_type_raises(
self, fake_repo: _FakeFeedbackRepository, fake_storage: _FakeStorage
):
from v1.feedback.service import FeedbackService
service = FeedbackService(repository=fake_repo, storage=fake_storage)
images = [_FakeUploadFile(content_type="image/gif")]
with pytest.raises(ApiProblemError) as exc_info:
await service.submit_feedback(
feedback_type="bug",
content="Test",
device_info={"platform": "ios", "model": "iPhone 15"},
app_version="1.0.0",
os_version="iOS 17.0",
images=images, # type: ignore[arg-type]
user_id=None,
)
assert exc_info.value.code == "FEEDBACK_INVALID_IMAGE_TYPE"
@pytest.mark.asyncio
async def test_image_too_large_raises(
self, fake_repo: _FakeFeedbackRepository, fake_storage: _FakeStorage
):
from v1.feedback.service import FeedbackService
service = FeedbackService(repository=fake_repo, storage=fake_storage)
large_content = b"x" * (6 * 1024 * 1024)
images = [_FakeUploadFile(content=large_content, content_type="image/jpeg")]
with pytest.raises(ApiProblemError) as exc_info:
await service.submit_feedback(
feedback_type="bug",
content="Test",
device_info={"platform": "ios", "model": "iPhone 15"},
app_version="1.0.0",
os_version="iOS 17.0",
images=images, # type: ignore[arg-type]
user_id=None,
)
assert exc_info.value.code == "FEEDBACK_IMAGE_TOO_LARGE"
@pytest.mark.asyncio
async def test_invalid_feedback_type_raises(
self, fake_repo: _FakeFeedbackRepository, fake_storage: _FakeStorage
):
from v1.feedback.service import FeedbackService
service = FeedbackService(repository=fake_repo, storage=fake_storage)
with pytest.raises(ApiProblemError) as exc_info:
await service.submit_feedback(
feedback_type="invalid_type",
content="Test",
device_info={"platform": "ios", "model": "iPhone 15"},
app_version="1.0.0",
os_version="iOS 17.0",
images=[],
user_id=None,
)
assert exc_info.value.code == "REQUEST_VALIDATION_ERROR"
@pytest.mark.asyncio
async def test_storage_upload_failure_raises_submit_failed(
self, fake_repo: _FakeFeedbackRepository
):
from v1.feedback.service import FeedbackService
class _FailingStorage:
async def upload_bytes(self, **kwargs: object) -> str:
raise RuntimeError("Storage unavailable")
service = FeedbackService(
repository=fake_repo,
storage=_FailingStorage(), # type: ignore[arg-type]
)
images = [_FakeUploadFile()]
with pytest.raises(ApiProblemError) as exc_info:
await service.submit_feedback(
feedback_type="bug",
content="Test",
device_info={"platform": "ios", "model": "iPhone 15"},
app_version="1.0.0",
os_version="iOS 17.0",
images=images, # type: ignore[arg-type]
user_id=None,
)
assert exc_info.value.code == "FEEDBACK_SUBMIT_FAILED"
+11
View File
@@ -89,6 +89,17 @@ This document is the source of truth for backend RFC7807 `code` values consumed
|---|---:|---|---|
| `INVITE_CODE_NOT_FOUND` | 404 | Invite code not found for current user | Show not-found message and trigger invite code bootstrap |
## Feedback
| code | status | meaning | frontend handling |
|---|---:|---|---|
| `FEEDBACK_CONTENT_EMPTY` | 400 | Feedback content is empty | Prompt user to enter content |
| `FEEDBACK_CONTENT_TOO_LONG` | 400 | Feedback content exceeds 500 characters | Show character limit hint |
| `FEEDBACK_TOO_MANY_IMAGES` | 400 | More than 3 images uploaded | Show image count limit hint |
| `FEEDBACK_IMAGE_TOO_LARGE` | 400 | Image file exceeds 5 MB | Show image size limit hint |
| `FEEDBACK_INVALID_IMAGE_TYPE` | 400 | Image type not supported (only jpg/png) | Show supported format hint |
| `FEEDBACK_SUBMIT_FAILED` | 500 | Feedback submission failed | Show retry prompt |
## Global
| code | status | meaning | frontend handling |
@@ -0,0 +1,152 @@
# Feedback Protocol (Frontend <-> Backend)
This document defines the canonical backend contract for user feedback submission.
Protocol verification status:
- Backend route source: `backend/src/v1/feedback/router.py`
- Backend schema source: `backend/src/v1/feedback/schemas.py`
- Backend service source: `backend/src/v1/feedback/service.py`
- Backend dependencies source: `backend/src/v1/feedback/dependencies.py`
- Backend model source: `backend/src/models/user_feedback.py`
- Backend report source: `backend/src/v1/feedback/report.py`
- Backend tasks source: `backend/src/v1/feedback/tasks.py`
- Backend email source: `backend/src/core/email/sender.py`
- Frontend mapping source: `apps/lib/features/settings/data/apis/feedback_api.dart`
- Storage config source: `backend/src/core/config/settings.py`
- Current status: Phase 1 + Phase 2 + Phase 3 implemented
## Compatibility strategy
- Current strategy: additive evolution (`backward-compatible`).
- Breaking change requires explicit migration + rollback notes (`requires-migration`).
## Route overview
- Submit feedback: `POST /api/v1/feedback` (multipart/form-data)
## Auth and trust boundary
- Feedback submission supports both authenticated and anonymous modes.
- Authenticated: `Authorization: Bearer {token}` header. Backend extracts `user_id` from JWT.
- Anonymous: No `Authorization` header. `user_id` stored as `NULL`.
- Frontend controls anonymity via checkbox ("Do not upload my personal information").
## Submit feedback
### Request
```
POST /api/v1/feedback
Content-Type: multipart/form-data
Authorization: Bearer {token} # Optional
Form Fields:
- feedback_type: "bug" | "suggestion" | "other" (required)
- content: string (required, 1-500 chars after trim)
- device_info: string (JSON, e.g. {"platform":"ios","model":"iPhone 15"})
- app_version: string (required)
- os_version: string (required)
Files:
- images: File[] (optional, max 3 files, jpg/png only, max 5MB each)
```
### Response 201
```json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"created_at": "2026-04-17T10:30:00+00:00"
}
```
### Error codes
| code | status | meaning |
|---|---:|---|
| `FEEDBACK_CONTENT_EMPTY` | 400 | Content is empty or whitespace-only |
| `FEEDBACK_CONTENT_TOO_LONG` | 400 | Content exceeds 500 characters |
| `FEEDBACK_TOO_MANY_IMAGES` | 400 | More than 3 images uploaded |
| `FEEDBACK_IMAGE_TOO_LARGE` | 400 | Single image exceeds 5MB |
| `FEEDBACK_INVALID_IMAGE_TYPE` | 400 | Image type not jpg/png |
| `FEEDBACK_SUBMIT_FAILED` | 500 | Internal storage or database failure |
| `REQUEST_VALIDATION_ERROR` | 422 | Invalid feedback_type or device_info JSON |
## Database schema
Table: `user_feedback`
| Column | Type | Nullable | Default | Description |
|---|---|---|---|---|
| `id` | UUID | NO | `gen_random_uuid()` | Primary key |
| `user_id` | UUID | YES | - | FK to auth.users, NULL = anonymous |
| `feedback_type` | VARCHAR(20) | NO | `'other'` | bug/suggestion/other |
| `content` | TEXT | NO | - | Feedback content, max 500 chars |
| `images` | JSONB | NO | `'[]'` | Storage path list, max 3 |
| `device_info` | JSONB | NO | `'{}'` | Device info JSON |
| `app_version` | VARCHAR(20) | NO | - | App version string |
| `os_version` | VARCHAR(50) | NO | - | OS version string |
| `status` | VARCHAR(20) | NO | `'pending'` | pending/processed |
| `created_at` | TIMESTAMPTZ | NO | `now()` | Creation timestamp |
| `updated_at` | TIMESTAMPTZ | NO | `now()` | Last update timestamp |
## Storage
- Bucket: configured via `ERYAO_STORAGE__FEEDBACK__BUCKET` (default: `feedback-images`)
- Visibility: private (no public access needed)
- Path pattern: `{YYYY-MM-DD}/{timestamp}_{index}.{ext}`
- Max file size: configured via `ERYAO_STORAGE__FEEDBACK__MAX_SIZE_MB` (default: 5)
- Allowed types: `image/jpeg`, `image/png`
## Frontend implementation notes
- Uses `image_picker` package for image selection
- Uses `dio` `FormData` for multipart upload
- Anonymous mode: uses `Options(extra: {'skipAuth': true})` to bypass token injection in ApiClient interceptor
- Client-side validation: content required, max 500 chars, max 3 images
- Server-side validation: all field validation happens in `FeedbackService`
## Phase 2: Report Generation (Implemented)
- **Report format**: xlsx (openpyxl) with embedded images
- **Image columns**: Screenshots anchored to columns K/L/M
- **Report path**: `{log_dir}/reports/{YYYYMMDD_HHMMSS}_feedback_report.xlsx`
- **Status transition**: `pending` -> `processed` after report generation
## Phase 3: Email Delivery (Implemented)
- **Email service**: `core/email/sender.py` (aiosmtplib, stateless)
- **SMTP provider**: Feishu enterprise email (`smtp.feishu.cn:465`)
- **Templates**: `core/email/templates/feedback/`
- `daily_report.html`: HTML email with stats cards (sent when feedbacks exist)
- `no_feedback.html`: Plain email (sent when no feedbacks in time range)
- **Attachment naming**: `{YYYYMMDD_HHMMSS}_feedback_report.xlsx`
- **Schedule**: Configured via `ERYAO_FEEDBACK_REPORT__CRON` (default: `0 10 * * *`)
- **Enable flag**: `ERYAO_FEEDBACK_REPORT__ENABLED` (default: `false`)
### Email configuration
```bash
ERYAO_EMAIL__HOST=smtp.feishu.cn
ERYAO_EMAIL__PORT=465
ERYAO_EMAIL__USE_SSL=true
ERYAO_EMAIL__USERNAME=<email address>
ERYAO_EMAIL__PASSWORD=<SMTP password>
ERYAO_EMAIL__FROM_ADDRESS=<email address>
ERYAO_EMAIL__FROM_NAME=Eryao Feedback System
ERYAO_FEEDBACK_REPORT__EMAIL=<recipient email>
```
### Email template variables
| Variable | Description |
|---|---|
| `${start_date}` | Report period start date (YYYY-MM-DD) |
| `${start_hour}` | Report period start hour |
| `${end_date}` | Report period end date (YYYY-MM-DD) |
| `${end_hour}` | Report period end hour |
| `${total_count}` | Total feedback count |
| `${bug_count}` | Bug type count |
| `${suggestion_count}` | Suggestion type count |
| `${generated_at}` | Report generation timestamp |
+1 -1
View File
@@ -153,7 +153,7 @@ start() {
WEB_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=web uv run uvicorn app:app --host ${ERYAO_WEB__HOST:-0.0.0.0} --port ${WEB_PORT} --workers ${ERYAO_WEB__WORKERS:-2} --log-level ${UVICORN_LOG_LEVEL}"
WORKER_AGENT_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=worker-agent uv run taskiq worker core.taskiq.app:worker_agent_broker core.agentscope.runtime.tasks --workers ${ERYAO_WORKER__GROUPS__AGENT__CONCURRENCY:-2}"
WORKER_GENERAL_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=worker-general uv run taskiq worker core.taskiq.app:worker_general_broker core.agentscope.runtime.tasks --workers ${ERYAO_WORKER__GROUPS__GENERAL__CONCURRENCY:-1}"
WORKER_GENERAL_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=worker-general uv run taskiq worker core.taskiq.app:worker_general_broker core.agentscope.runtime.tasks v1.feedback.tasks --workers ${ERYAO_WORKER__GROUPS__GENERAL__CONCURRENCY:-1}"
echo "Starting tmux web process in session '$SESSION_NAME'..."
+3
View File
@@ -6,6 +6,7 @@ requires-python = ">=3.12"
dependencies = [
"ag-ui-protocol==0.1.13",
"agentscope>=1.0.18",
"aiosmtplib>=5.1.0",
"alembic==1.18.4",
"asyncpg==0.30.0",
"cryptography==46.0.3",
@@ -13,6 +14,8 @@ dependencies = [
"email-validator==2.3.0",
"fastapi==0.135.1",
"lunar-python>=1.4.8",
"openpyxl>=3.1.5",
"pillow>=12.2.0",
"pydantic==2.12.5",
"pydantic-settings==2.13.1",
"pyjwt==2.11.0",