211 lines
6.7 KiB
Dart
211 lines
6.7 KiB
Dart
import 'dart:math';
|
|
import 'dart:typed_data';
|
|
|
|
enum FeedbackKind { problem, development }
|
|
|
|
class FeedbackException implements Exception {
|
|
const FeedbackException(this.message);
|
|
final String message;
|
|
@override
|
|
String toString() => message;
|
|
}
|
|
|
|
class FeedbackDeliveryUncertain extends FeedbackException {
|
|
const FeedbackDeliveryUncertain(this.submissionId) : super(instructions);
|
|
final String submissionId;
|
|
static const instructions =
|
|
'邮件可能已经发送,但结果尚未确认。请先联系开发者按提交编号核对收件箱;确认前请勿修改内容后重新提交或创建新反馈。';
|
|
}
|
|
|
|
abstract interface class FeedbackSender {
|
|
/// Completes only when the server confirms acceptance. Use [FeedbackSubmission.id]
|
|
/// as the server's idempotency key so retrying a timed-out request is safe.
|
|
Future<void> send(FeedbackSubmission submission);
|
|
}
|
|
|
|
class UnconfiguredFeedbackSender implements FeedbackSender {
|
|
const UnconfiguredFeedbackSender();
|
|
static const message = '反馈服务暂未配置,暂时无法提交。你的填写内容已保留,请稍后再试。';
|
|
@override
|
|
Future<void> send(FeedbackSubmission submission) async {
|
|
throw const FeedbackException(message);
|
|
}
|
|
}
|
|
|
|
class FeedbackLimits {
|
|
const FeedbackLimits({
|
|
this.maxAttachments = 3,
|
|
this.maxFileBytes = 8 * 1024 * 1024,
|
|
this.maxTotalBytes = 20 * 1024 * 1024,
|
|
});
|
|
final int maxAttachments;
|
|
final int maxFileBytes;
|
|
final int maxTotalBytes;
|
|
static const defaults = FeedbackLimits();
|
|
}
|
|
|
|
class FeedbackAttachment {
|
|
FeedbackAttachment._(this.filename, this.contentType, Uint8List bytes)
|
|
: bytes = bytes.asUnmodifiableView();
|
|
|
|
final String filename;
|
|
final String contentType;
|
|
final Uint8List bytes;
|
|
int get size => bytes.length;
|
|
|
|
static const supportedTypes = {
|
|
'jpg': 'image/jpeg',
|
|
'jpeg': 'image/jpeg',
|
|
'png': 'image/png',
|
|
'gif': 'image/gif',
|
|
'webp': 'image/webp',
|
|
'heic': 'image/heic',
|
|
'mp4': 'video/mp4',
|
|
'mov': 'video/quicktime',
|
|
'm4v': 'video/x-m4v',
|
|
'webm': 'video/webm',
|
|
};
|
|
|
|
static Future<FeedbackAttachment> read({
|
|
required String name,
|
|
required Stream<List<int>> stream,
|
|
int maxBytes = 8 * 1024 * 1024,
|
|
}) async {
|
|
final basename = name.replaceAll('\\', '/').split('/').last;
|
|
final safeName = basename
|
|
.replaceAll(RegExp(r'[\x00-\x1f\x7f<>:"/\\|?*]'), '')
|
|
.trim();
|
|
final extension = safeName.split('.').last.toLowerCase();
|
|
final type = supportedTypes[extension];
|
|
if (type == null || safeName.length > 150) {
|
|
throw const FeedbackException('请选择受支持的图片或视频,文件名应少于 150 个字符');
|
|
}
|
|
final buffer = BytesBuilder(copy: false);
|
|
var count = 0;
|
|
await for (final chunk in stream) {
|
|
count += chunk.length;
|
|
if (count > maxBytes) {
|
|
throw FeedbackException('单个附件不能超过 ${_megabytes(maxBytes)} MB');
|
|
}
|
|
buffer.add(chunk);
|
|
}
|
|
if (count == 0) throw const FeedbackException('不能添加空文件');
|
|
return FeedbackAttachment._(safeName, type, buffer.takeBytes());
|
|
}
|
|
}
|
|
|
|
class FeedbackSubmission {
|
|
FeedbackSubmission._({
|
|
required this.id,
|
|
required this.kind,
|
|
this.message,
|
|
this.contact,
|
|
this.school,
|
|
this.attachments = const [],
|
|
this.diagnosticLog,
|
|
this.diagnosticHtml,
|
|
});
|
|
|
|
factory FeedbackSubmission.problem({
|
|
required String id,
|
|
required String message,
|
|
String? contact,
|
|
List<FeedbackAttachment> attachments = const [],
|
|
FeedbackLimits limits = FeedbackLimits.defaults,
|
|
Uint8List? diagnosticLog,
|
|
Uint8List? diagnosticHtml,
|
|
}) {
|
|
final text = message.trim();
|
|
if (text.isEmpty) throw const FeedbackException('请填写问题或建议');
|
|
if (text.length > 5000) throw const FeedbackException('问题或建议不能超过 5000 个字符');
|
|
_validateContact(contact);
|
|
validateAttachments(attachments, limits);
|
|
if (diagnosticLog != null &&
|
|
(diagnosticLog.isEmpty || diagnosticLog.length > 256 * 1024)) {
|
|
throw const FeedbackException('日志附件不能超过256 KB');
|
|
}
|
|
if (diagnosticHtml != null &&
|
|
(diagnosticHtml.isEmpty || diagnosticHtml.length > 2 * 1024 * 1024)) {
|
|
throw const FeedbackException('网页排查附件不能超过2 MB');
|
|
}
|
|
return FeedbackSubmission._(
|
|
id: id,
|
|
kind: FeedbackKind.problem,
|
|
message: text,
|
|
contact: _optional(contact),
|
|
attachments: List.unmodifiable(attachments),
|
|
diagnosticLog: diagnosticLog == null
|
|
? null
|
|
: Uint8List.fromList(diagnosticLog).asUnmodifiableView(),
|
|
diagnosticHtml: diagnosticHtml == null
|
|
? null
|
|
: Uint8List.fromList(diagnosticHtml).asUnmodifiableView(),
|
|
);
|
|
}
|
|
|
|
factory FeedbackSubmission.development({
|
|
required String id,
|
|
required String school,
|
|
required String contact,
|
|
}) {
|
|
final schoolName = school.trim();
|
|
if (schoolName.isEmpty) throw const FeedbackException('请填写学校');
|
|
if (schoolName.length > 200) {
|
|
throw const FeedbackException('学校名称不能超过 200 个字符');
|
|
}
|
|
if (contact.trim().isEmpty) throw const FeedbackException('请填写联系方式');
|
|
_validateContact(contact);
|
|
return FeedbackSubmission._(
|
|
id: id,
|
|
kind: FeedbackKind.development,
|
|
school: schoolName,
|
|
contact: contact.trim(),
|
|
);
|
|
}
|
|
|
|
final String id;
|
|
final FeedbackKind kind;
|
|
final String? message;
|
|
final String? contact;
|
|
final String? school;
|
|
final List<FeedbackAttachment> attachments;
|
|
final Uint8List? diagnosticLog;
|
|
final Uint8List? diagnosticHtml;
|
|
|
|
static String newId() {
|
|
final random = Random.secure();
|
|
return List.generate(
|
|
16,
|
|
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
|
|
).join();
|
|
}
|
|
|
|
static void validateAttachments(
|
|
List<FeedbackAttachment> files,
|
|
FeedbackLimits limits,
|
|
) {
|
|
if (files.length > limits.maxAttachments) {
|
|
throw FeedbackException('最多添加 ${limits.maxAttachments} 个附件');
|
|
}
|
|
if (files.any((file) => file.size > limits.maxFileBytes)) {
|
|
throw FeedbackException('单个附件不能超过 ${_megabytes(limits.maxFileBytes)} MB');
|
|
}
|
|
if (files.fold<int>(0, (total, file) => total + file.size) >
|
|
limits.maxTotalBytes) {
|
|
throw FeedbackException(
|
|
'附件总大小不能超过 ${_megabytes(limits.maxTotalBytes)} MB',
|
|
);
|
|
}
|
|
}
|
|
|
|
static String? _optional(String? value) =>
|
|
value == null || value.trim().isEmpty ? null : value.trim();
|
|
static void _validateContact(String? contact) {
|
|
if ((contact?.trim().length ?? 0) > 200) {
|
|
throw const FeedbackException('联系方式不能超过 200 个字符');
|
|
}
|
|
}
|
|
}
|
|
|
|
String _megabytes(int bytes) => (bytes / (1024 * 1024)).toStringAsFixed(0);
|