Add feedback service, reminder diagnostics and smooth week transitions
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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');
|
||||
}
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user