217 lines
7.6 KiB
Dart
217 lines
7.6 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') {
|
|
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);
|
|
}
|