import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'feedback_submission.dart'; class FeedbackHttpResponse { const FeedbackHttpResponse(this.statusCode, this.body); final int statusCode; final List body; } abstract interface class FeedbackHttpTransport { Future post({ required Uri endpoint, required Map headers, required Uint8List body, required Duration timeout, }); } class IoFeedbackHttpTransport implements FeedbackHttpTransport { const IoFeedbackHttpTransport(); @override Future post({ required Uri endpoint, required Map headers, required Uint8List body, required Duration timeout, }) async { final client = HttpClient()..connectionTimeout = timeout; Future request() async { final request = await client.postUrl(endpoint); request.followRedirects = false; headers.forEach(request.headers.set); request.contentLength = body.length; request.add(body); final response = await request.close(); final bytes = BytesBuilder(copy: false); var size = 0; await for (final chunk in response) { size += chunk.length; if (size > HttpFeedbackSender.maxResponseBytes) { throw const FeedbackException('反馈服务返回异常,请稍后重试'); } bytes.add(chunk); } return FeedbackHttpResponse(response.statusCode, bytes.takeBytes()); } try { return await request().timeout(timeout); } finally { client.close(force: true); } } } class HttpFeedbackSender implements FeedbackSender { const HttpFeedbackSender({ required this.endpoint, this.transport = const IoFeedbackHttpTransport(), this.timeout = const Duration(seconds: 30), }); static const maxResponseBytes = 64 * 1024; final String endpoint; final FeedbackHttpTransport transport; final Duration timeout; @override Future send(FeedbackSubmission submission) async { if (endpoint.trim().isEmpty) { throw const FeedbackException(UnconfiguredFeedbackSender.message); } final uri = Uri.tryParse(endpoint.trim()); if (uri == null || uri.scheme != 'https' || uri.host.isEmpty || uri.userInfo.isNotEmpty || uri.fragment.isNotEmpty) { throw const FeedbackException('反馈服务配置无效,请使用安全的 HTTPS 服务地址'); } FeedbackSubmission.validateAttachments( submission.attachments, FeedbackLimits.defaults, ); final boundary = 'TPSchedule${FeedbackSubmission.newId()}'; final body = _multipart(submission, boundary); try { final response = await transport .post( endpoint: uri, headers: { 'Content-Type': 'multipart/form-data; boundary=$boundary', 'Accept': 'application/json', }, body: body, timeout: timeout, ) .timeout(timeout); if (response.body.length > maxResponseBytes) { throw const FeedbackException('反馈服务返回异常,请稍后重试'); } final result = _readResult(response.body); final matchesSubmission = result?['submission_id'] == submission.id; final errorCode = result?['error']; if (response.statusCode == 503 && matchesSubmission && result?['status'] == 'uncertain' && (errorCode == 'delivery_uncertain' || errorCode == 'storage_unavailable')) { throw FeedbackDeliveryUncertain(submission.id); } if (response.statusCode == 413) { throw const FeedbackException('附件超过服务器限制,请减少附件后重试'); } if (response.statusCode == 415 && errorCode == 'unsupported_media') { throw const FeedbackException('服务器不支持此附件的格式或内容,请移除附件,或换用正常的图片、视频后提交'); } if (response.statusCode == 422 && errorCode == 'validation_error') { if ((submission.diagnosticLog != null || submission.diagnosticHtml != null) && result?['detail'] == 'Unexpected file field.') { throw const FeedbackException( '反馈服务器尚未更新日志或网页附件协议,请先更新服务器,或取消附带排查附件后提交。填写内容已保留。', ); } throw const FeedbackException('填写内容未通过服务器校验,请检查必填内容、长度及附件后重新提交'); } if (response.statusCode == 409 && matchesSubmission) { if (errorCode == 'in_progress') { throw const FeedbackException('原提交正在处理,请稍后重试原提交;请勿修改内容或创建新反馈'); } if (errorCode == 'id_conflict') { throw FeedbackDeliveryUncertain(submission.id); } } if (response.statusCode == 429) { throw const FeedbackException('提交太频繁,请稍后重试'); } if (response.statusCode == 503 && matchesSubmission && errorCode == 'smtp_unavailable' && result?['status'] == 'retryable') { throw const FeedbackException('邮件服务暂时不可用,邮件尚未被接收;请保留内容,稍后重试原提交'); } if (response.statusCode < 200 || response.statusCode >= 300) { throw const FeedbackException('未收到提交成功确认,请保留内容,稍后重试原提交;请勿修改后重复提交'); } if (result?['success'] != true || !matchesSubmission || result?['status'] != 'sent') { throw const FeedbackException('尚未收到提交成功确认,请稍后重试'); } } on FeedbackException { rethrow; } on TimeoutException { throw const FeedbackException('提交超时,请稍后重试。填写内容和附件已保留。'); } on FormatException { throw const FeedbackException('反馈服务返回异常,请稍后重试'); } catch (_) { throw const FeedbackException('无法连接反馈服务,请检查网络后重试'); } } Map? _readResult(List body) { try { final result = jsonDecode(utf8.decode(body)); return result is Map ? result : null; } on FormatException { return null; } } Uint8List _multipart(FeedbackSubmission submission, String boundary) { final bytes = BytesBuilder(copy: false); void write(String text) => bytes.add(utf8.encode(text)); void field(String name, String value) { write( '--$boundary\r\nContent-Disposition: form-data; name="$name"\r\n\r\n$value\r\n', ); } field('submission_id', submission.id); field('type', submission.kind.name); if (submission.kind == FeedbackKind.problem) { field('message', submission.message!); } else { field('school', submission.school!); } if (submission.contact != null) field('contact', submission.contact!); for (final attachment in submission.attachments) { write( '--$boundary\r\nContent-Disposition: form-data; name="attachments"; filename="${attachment.filename}"\r\n' 'Content-Type: ${attachment.contentType}\r\n\r\n', ); bytes.add(attachment.bytes); write('\r\n'); } if (submission.diagnosticLog != null) { write( '--$boundary\r\nContent-Disposition: form-data; name="diagnostics"; filename="TPSchedule-diagnostics.txt"\r\n' 'Content-Type: text/plain; charset=utf-8\r\n\r\n', ); bytes.add(submission.diagnosticLog!); write('\r\n'); } if (submission.diagnosticHtml != null) { write( '--$boundary\r\nContent-Disposition: form-data; name="diagnostic_html"; filename="TPSchedule-school-page.html"\r\n' 'Content-Type: text/html; charset=utf-8\r\n\r\n', ); bytes.add(submission.diagnosticHtml!); write('\r\n'); } write('--$boundary--\r\n'); return bytes.takeBytes(); } } FeedbackSender configuredFeedbackSender() { const endpoint = String.fromEnvironment( 'FEEDBACK_ENDPOINT', defaultValue: 'https://app.glowtps.cn/v1/feedback', ); return endpoint.trim().isEmpty ? const UnconfiguredFeedbackSender() : const HttpFeedbackSender(endpoint: endpoint); }