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:
@@ -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": "创建 FeedbackRepository(CRUD 操作)", "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 模型",
|
||||
"创建 FeedbackRepository(CRUD 操作)",
|
||||
"创建 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 环境变量配置"
|
||||
]
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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 |
|
||||
@@ -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'..."
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user