Files
qzl 6a2a9d2c87 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
2026-04-20 12:49:54 +08:00

139 lines
3.9 KiB
Dart

import 'package:dio/dio.dart';
import '../../core/logging/logger.dart';
import '../../core/network/api_problem.dart';
class ApiClient {
ApiClient({
required String baseUrl,
Future<String?> Function()? tokenProvider,
Future<void> Function()? onUnauthorized,
}) : _dio = Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
headers: const {'Content-Type': 'application/json'},
),
) {
if (tokenProvider != null) {
_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';
}
handler.next(options);
},
onError: (error, handler) async {
final status = error.response?.statusCode;
final authHeader =
error.requestOptions.headers['Authorization'] as String?;
final hasAuthHeader = authHeader != null && authHeader.isNotEmpty;
final isLogoutEndpoint =
error.requestOptions.method.toUpperCase() == 'DELETE' &&
error.requestOptions.path == '/api/v1/auth/sessions';
if (status == 401 &&
hasAuthHeader &&
onUnauthorized != null &&
!isLogoutEndpoint) {
await onUnauthorized();
}
handler.next(error);
},
),
);
}
}
final Dio _dio;
final Logger _logger = getLogger('data.network.api_client');
Dio get rawDio => _dio;
Future<void> postNoContent(String path, {Map<String, dynamic>? data}) async {
try {
await _dio.post<void>(path, data: data);
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'POST no-content failed',
error: error,
stackTrace: stackTrace,
);
throw _mapProblem(error);
}
}
Future<void> deleteNoContent(
String path, {
Map<String, dynamic>? data,
}) async {
try {
await _dio.delete<void>(path, data: data);
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'DELETE no-content failed',
error: error,
stackTrace: stackTrace,
);
throw _mapProblem(error);
}
}
Future<Map<String, dynamic>> postJson(
String path, {
Map<String, dynamic>? data,
}) async {
try {
final response = await _dio.post<Map<String, dynamic>>(path, data: data);
return response.data ?? <String, dynamic>{};
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'POST json failed',
error: error,
stackTrace: stackTrace,
);
throw _mapProblem(error);
}
}
Future<Map<String, dynamic>> getJson(String path) async {
try {
final response = await _dio.get<Map<String, dynamic>>(path);
return response.data ?? <String, dynamic>{};
} on DioException catch (error, stackTrace) {
_logger.error(
message: 'GET json 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',
);
}
}