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:
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user