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

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

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

Protocol:
- Document feedback API contract and error codes
- Update http-error-codes.md with FEEDBACK_* codes
This commit is contained in:
qzl
2026-04-20 12:49:54 +08:00
parent 913ed26f8d
commit 6a2a9d2c87
46 changed files with 4768 additions and 9 deletions
@@ -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,
);
}
}