Files
TPSchedule/mobile/lib/features/feedback/feedback_http_sender.dart
T

223 lines
7.9 KiB
Dart

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<int> body;
}
abstract interface class FeedbackHttpTransport {
Future<FeedbackHttpResponse> post({
required Uri endpoint,
required Map<String, String> headers,
required Uint8List body,
required Duration timeout,
});
}
class IoFeedbackHttpTransport implements FeedbackHttpTransport {
const IoFeedbackHttpTransport();
@override
Future<FeedbackHttpResponse> post({
required Uri endpoint,
required Map<String, String> headers,
required Uint8List body,
required Duration timeout,
}) async {
final client = HttpClient()..connectionTimeout = timeout;
Future<FeedbackHttpResponse> 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<void> 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 &&
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<String, dynamic>? _readResult(List<int> body) {
try {
final result = jsonDecode(utf8.decode(body));
return result is Map<String, dynamic> ? 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');
}
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);
}