import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart' as http; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; const int _maxFileSize = 10 * 1024 * 1024; // 10MB const int _maxRetries = 3; class UploadResult { final bool success; final String? transactionId; final String? error; UploadResult.success(this.transactionId) : success = true, error = null; UploadResult.failure(this.error) : success = false, transactionId = null; } class UploadService { static Future _getServerUrl() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString('server_url') ?? ''; } static Future _getToken() async { final prefs = await SharedPreferences.getInstance(); return prefs.getString('api_token') ?? ''; } static Future upload({ required File imageFile, required String originalFilename, String? userNote, }) async { // Client-side size check final fileSize = await imageFile.length(); if (fileSize > _maxFileSize) { return UploadResult.failure('图片过大,请选择小于10MB的图片'); } final serverUrl = await _getServerUrl(); final token = await _getToken(); if (serverUrl.isEmpty) { return UploadResult.failure('请先配置服务器地址'); } if (token.isEmpty) { return UploadResult.failure('请先配置 API Token'); } return _doUpload( serverUrl: serverUrl, token: token, imageFile: imageFile, originalFilename: originalFilename, userNote: userNote, ); } static Future _doUpload({ required String serverUrl, required String token, required File imageFile, required String originalFilename, String? userNote, int attempt = 0, }) async { try { final uri = Uri.parse('$serverUrl/api/v1/transactions/upload'); final request = http.MultipartRequest('POST', uri); request.headers['Authorization'] = 'Bearer $token'; request.files.add(await http.MultipartFile.fromPath('file', imageFile.path)); request.fields['original_filename'] = originalFilename; if (userNote != null && userNote.isNotEmpty) { request.fields['user_note'] = userNote; } final response = await request.send().timeout(const Duration(seconds: 30)); final body = await response.stream.bytesToString(); final json = jsonDecode(body) as Map; if (response.statusCode == 200 && json['success'] == true) { return UploadResult.success(json['data']?['id']?.toString()); } else { return UploadResult.failure(json['error'] as String? ?? 'Upload failed'); } } on SocketException catch (_) { // Network error — cache for retry await _cacheForRetry( imageFile: imageFile, originalFilename: originalFilename, userNote: userNote, ); if (attempt < _maxRetries) { final delay = Duration(seconds: 5 * (1 << attempt)); await Future.delayed(delay); return _doUpload( serverUrl: serverUrl, token: token, imageFile: imageFile, originalFilename: originalFilename, userNote: userNote, attempt: attempt + 1, ); } return UploadResult.failure('上传失败,请检查网络'); } catch (e) { return UploadResult.failure('上传失败: $e'); } } static Future _cacheForRetry({ required File imageFile, required String originalFilename, String? userNote, }) async { try { final dir = await getApplicationDocumentsDirectory(); final cacheDir = Directory('${dir.path}/pending_uploads'); if (!await cacheDir.exists()) { await cacheDir.create(recursive: true); } final timestamp = DateTime.now().millisecondsSinceEpoch; final cachedFile = await imageFile.copy('${cacheDir.path}/$timestamp.jpg'); final meta = { 'imagePath': cachedFile.path, 'originalFilename': originalFilename, 'userNote': userNote ?? '', }; final metaFile = File('${cacheDir.path}/$timestamp.json'); await metaFile.writeAsString(jsonEncode(meta)); } catch (_) { // Best-effort caching } } static Future retryCachedUploads() async { final dir = await getApplicationDocumentsDirectory(); final cacheDir = Directory('${dir.path}/pending_uploads'); if (!await cacheDir.exists()) return; final serverUrl = await _getServerUrl(); final token = await _getToken(); if (serverUrl.isEmpty || token.isEmpty) return; final metaFiles = cacheDir.listSync().where((f) => f.path.endsWith('.json')); for (final metaFile in metaFiles) { try { final meta = jsonDecode(await File(metaFile.path).readAsString()) as Map; final imageFile = File(meta['imagePath'] as String); if (!await imageFile.exists()) continue; final result = await upload( imageFile: imageFile, originalFilename: meta['originalFilename'] as String, userNote: meta['userNote'] as String, ); if (result.success) { await imageFile.delete(); await metaFile.delete(); } } catch (_) { // Skip problematic cache entries } } } }