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

191 lines
5.9 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 [],
});
factory FeedbackSubmission.problem({
required String id,
required String message,
String? contact,
List<FeedbackAttachment> attachments = const [],
FeedbackLimits limits = FeedbackLimits.defaults,
}) {
final text = message.trim();
if (text.isEmpty) throw const FeedbackException('请填写问题或建议');
if (text.length > 5000) throw const FeedbackException('问题或建议不能超过 5000 个字符');
_validateContact(contact);
validateAttachments(attachments, limits);
return FeedbackSubmission._(
id: id,
kind: FeedbackKind.problem,
message: text,
contact: _optional(contact),
attachments: List.unmodifiable(attachments),
);
}
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;
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);