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 send(FeedbackSubmission submission); } class UnconfiguredFeedbackSender implements FeedbackSender { const UnconfiguredFeedbackSender(); static const message = '反馈服务暂未配置,暂时无法提交。你的填写内容已保留,请稍后再试。'; @override Future 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 read({ required String name, required Stream> 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, }); factory FeedbackSubmission.problem({ required String id, required String message, String? contact, List attachments = const [], FeedbackLimits limits = FeedbackLimits.defaults, Uint8List? diagnosticLog, }) { 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'); } return FeedbackSubmission._( id: id, kind: FeedbackKind.problem, message: text, contact: _optional(contact), attachments: List.unmodifiable(attachments), diagnosticLog: diagnosticLog == null ? null : Uint8List.fromList(diagnosticLog).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 attachments; final Uint8List? diagnosticLog; static String newId() { final random = Random.secure(); return List.generate( 16, (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), ).join(); } static void validateAttachments( List 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(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);