From 1f06e3741eac627fd29455dc1e653ee99b6cf132 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 14 Oct 2026 15:53:21 +0800 Subject: [PATCH] Add feedback service, reminder diagnostics and smooth week transitions --- README.md | 4 +- ...-10-14-tpschedule-feedback-and-reminder.md | 14 + docs/tpschedule-0.6.0-validation.md | 30 + .../kotlin/com/tpschedule/app/MainActivity.kt | 3 + .../tpschedule/app/ReminderSystemBridge.kt | 85 +++ mobile/lib/app/app_controller.dart | 16 +- mobile/lib/app/app_version.dart | 2 + mobile/lib/app/hepan_app.dart | 8 + .../feedback/feedback_http_sender.dart | 208 +++++++ .../lib/features/feedback/feedback_page.dart | 431 +++++++++++++++ .../feedback/feedback_submission.dart | 190 +++++++ mobile/lib/features/profile/profile_page.dart | 25 + .../lib/features/profile/reminder_tools.dart | 232 ++++++++ .../lib/features/profile/settings_page.dart | 3 + .../features/timetable/timetable_page.dart | 21 +- .../services/local_notification_service.dart | 134 ++++- mobile/lib/services/notification_service.dart | 9 + .../widgets/directional_week_transition.dart | 137 +++++ mobile/lib/widgets/glass_dock.dart | 6 +- mobile/pubspec.yaml | 2 +- .../features/feedback_http_sender_test.dart | 311 +++++++++++ mobile/test/features/feedback_test.dart | 320 +++++++++++ .../services/notification_gateway_test.dart | 130 ++++- .../services/notification_service_test.dart | 20 +- mobile/test/widgets/reminder_tools_test.dart | 142 +++++ mobile/test/widgets/week_swipe_test.dart | 40 ++ mobile/test/widgets/week_transition_test.dart | 159 ++++++ server/feedback/.dockerignore | 11 + server/feedback/.env.example | 17 + server/feedback/.gitignore | 10 + server/feedback/Dockerfile | 13 + server/feedback/README.md | 96 ++++ server/feedback/docker-compose.yml | 34 ++ server/feedback/feedback_service.py | 513 ++++++++++++++++++ server/feedback/main.py | 3 + server/feedback/requirements-dev.txt | 3 + server/feedback/requirements.txt | 15 + server/feedback/reverse-proxy.conf.example | 32 ++ server/feedback/tests/test_feedback.py | 500 +++++++++++++++++ server/feedback/部署说明.md | 78 +++ 40 files changed, 3982 insertions(+), 25 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-14-tpschedule-feedback-and-reminder.md create mode 100644 docs/tpschedule-0.6.0-validation.md create mode 100644 mobile/android/app/src/main/kotlin/com/tpschedule/app/ReminderSystemBridge.kt create mode 100644 mobile/lib/app/app_version.dart create mode 100644 mobile/lib/features/feedback/feedback_http_sender.dart create mode 100644 mobile/lib/features/feedback/feedback_page.dart create mode 100644 mobile/lib/features/feedback/feedback_submission.dart create mode 100644 mobile/lib/features/profile/reminder_tools.dart create mode 100644 mobile/lib/widgets/directional_week_transition.dart create mode 100644 mobile/test/features/feedback_http_sender_test.dart create mode 100644 mobile/test/features/feedback_test.dart create mode 100644 mobile/test/widgets/reminder_tools_test.dart create mode 100644 mobile/test/widgets/week_transition_test.dart create mode 100644 server/feedback/.dockerignore create mode 100644 server/feedback/.env.example create mode 100644 server/feedback/.gitignore create mode 100644 server/feedback/Dockerfile create mode 100644 server/feedback/README.md create mode 100644 server/feedback/docker-compose.yml create mode 100644 server/feedback/feedback_service.py create mode 100644 server/feedback/main.py create mode 100644 server/feedback/requirements-dev.txt create mode 100644 server/feedback/requirements.txt create mode 100644 server/feedback/reverse-proxy.conf.example create mode 100644 server/feedback/tests/test_feedback.py create mode 100644 server/feedback/部署说明.md diff --git a/README.md b/README.md index d59296b..ebf6ebb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # TPSchedule -学生课表 APP,Flutter 实现,目前接入电子科技大学研究生系统。Android 0.5.6+14,Android 7.0 及以上。 +学生课表 APP,Flutter 实现,目前接入电子科技大学研究生系统。Android 0.6.0+15,Android 7.0 及以上。 使用用户提供的原始 SVG 图标,蓝色渐变、黄色日历框及白色笔画保持一致;启动画面使用相同配色。0.5.1修复重复启动页闪烁:初始化期间保留原生启动屏,数据就绪后直接显示课表首帧,不再叠加Flutter品牌页或淡出动画。Android12+ 原生启动屏使用同色纯蓝背景及图标,旧版采用蓝色渐变。 @@ -18,6 +18,8 @@ 0.5.6为Dock图标增加蓝色柔光与浅色轮廓光晕,提高玻璃背景上的辨识度;未选中图标也增强蓝色填充。Dock尺寸、灰色静止滑块、按压回弹和文字保留。详见[0.5.6验证记录](docs/tpschedule-0.5.6-validation.md)。 +0.6.0将Dock光晕改为白光,换周增加过渡,修复通知查询失败阻断提醒,并增加通知测试与诊断。我的页面新增反馈与共建。服务器仍需配置部署,详见[0.6.0验证记录](docs/tpschedule-0.6.0-validation.md)与[中文1Panel部署说明](server/feedback/部署说明.md)。 + 0.5.4源码和安装包已冻结用于登记,位于dist/backups/TPSchedule-0.5.4-registration-20261014,Git标签tpschedule-0.5.4-registration。软著材料登记版本按用户要求为V1.0,使用冻结源码,未混入0.5.5功能,另存于output/pdf/TPSchedule-软著登记材料-V1.0。 ## 安装与学校登录 diff --git a/docs/superpowers/plans/2026-10-14-tpschedule-feedback-and-reminder.md b/docs/superpowers/plans/2026-10-14-tpschedule-feedback-and-reminder.md new file mode 100644 index 0000000..6104497 --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-tpschedule-feedback-and-reminder.md @@ -0,0 +1,14 @@ +# TPSchedule 0.6.0 实施记录 + +用户授权:白色 Dock 光晕;修复 Android 16 未收到课前提醒;左右换周增加过渡;我的页面增加问题建议及参与开发反馈,图片视频可选,腾讯云服务器通过飞书 SMTP 发信。 + +1. 保留 0.5.4 登记冻结和 V1.0 材料,保留原 Android 证书及包名。 +2. 将 Dock 柔光改成白色;换周使用短距离滑动、淡入淡出,快速操作只保留当前与最新目标,减少动画时直接切换。 +3. 将待发送与已显示通知分开查询,后者失败不阻断定时安排;安排后核对保存结果,保留安全错误码。 +4. 增加立即和 10 秒后台通知测试、权限/通道/后台状态检查及可复制诊断。测试提醒与正式课程独立。 +5. 制作两种反馈表单,校验必填字段;最多 3 个附件、单个 8 MiB、合计 20 MiB。开发参与不携带隐藏的问题内容或附件。 +6. 客户端 HTTPS multipart 对接 https://app.glowtps.cn/v1/feedback,稳定请求标识用于失败重试;只有服务器确认发信后提示成功。 +7. Ubuntu/1Panel 部署反馈服务,authors@glowtps.cn 发信,tps-david@glowtps.cn 收信。SMTP 密码只在服务器环境配置。 +8. 应用测试、分析、真实 Android 后台测试、后端假 SMTP 测试、独立代码审阅、APK 原证书检查及文档交付。 + +限制:开发环境未连接用户 vivo 真机;没有腾讯云管理会话、SSH 凭据或 SMTP 密码,不能声称已完成线上部署或真机送达验证。 diff --git a/docs/tpschedule-0.6.0-validation.md b/docs/tpschedule-0.6.0-validation.md new file mode 100644 index 0000000..c7d1814 --- /dev/null +++ b/docs/tpschedule-0.6.0-validation.md @@ -0,0 +1,30 @@ +# TPSchedule 0.6.0+15 验证记录 + +2026-10-14。包名 com.tpschedule.app,Android 7+,保持原图标、原签名及登记冻结文件。 + +## 本次改动 + +- 蓝色图标周围的柔光改为白光,灰色静止滑块、常驻 Dock、按住放大与回弹保持。 +- 换周采用280ms短距离移动和淡入淡出;快速请求合并到最新目标,过期层不接收触摸、焦点或无障碍操作,减少动画时直接切换。 +- 待发送和已显示通知分开查询。已显示查询失败不再阻断未来提醒;安排后核对系统保存记录,失败不显示虚假的已安排数量。 +- 课表设置增加立即测试、10秒后台测试、通知和后台设置入口、安全提醒诊断。测试与课程使用独立标识,诊断不含课程名、姓名、账号或原始异常。我的页面显示版本。 +- 我的页面新增反馈与共建。问题建议必填,联系及图片视频选填;参与开发必须填写学校及联系。最多3个附件、单个8MiB、合计20MiB。失败保留当前页面内容和请求标识,不确定发信结果进入原提交恢复页面。 +- HTTPS客户端与Ubuntu/1Panel服务配套,默认地址 https://app.glowtps.cn/v1/feedback。由authors@glowtps.cn发到tps-david@glowtps.cn。只有SMTP接受且持久记录后显示成功,密码留在服务器。 + +## 已执行验证 + +- 188项Flutter测试通过,静态分析无问题。已显示通知查询拒绝的回归先在旧实现失败,再在修复后通过。 +- 49项Python服务测试通过,使用假SMTP,无真实发信。覆盖字段、媒体格式/限额、上传截止时间、去重/重启、确定失败和不确定结果、SSL/STARTTLS、固定收发地址与限流。 +- release/debug APK构建通过。独立审阅通知、原生桥接、周次动画及客户端/服务端;上传时限和不确定错误提示问题已修复、复核。 +- Android16/API36模拟器覆盖安装保留11门课程及8门未排考信息。向左滑动切到第2周,三个标签可点击,实际检查反馈两种表单与提醒诊断。 +- 系统保存98次课前提醒。立即测试产生NotificationRecord(id=-1001,tag=hepan-test);10秒测试安排精确唤醒,按Home退到桌面后收到NotificationRecord(id=-1002,tag=hepan-test)和“TPSchedule 后台提醒测试”。均使用原课前提醒通道。诊断读到权限已允许和98条待发送。 +- 证书与0.5.6一致:AF5C21B1BA1E48E461D8EA38DF0FE1C3653F64EB7E8E7728BAE6E5D988BA0D0A。0.5.4登记备份18个文件散列一致,V1.0材料未编辑。 +- 发布APK为59,139,363字节。SHA256:086F72E3D8F75DBA1BD19EA23BD5C5C7DFAC83DE8640FD6DC11031E161D76E42。 + +## 验证范围 + +模拟器使用软件兼容渲染,不能替代vivo X200 Pro真机或手机玻璃渲染。vivo未收到提醒的具体原因仍需新版诊断确认,不能以模拟器成功声称真机已解决。可在我的→课表设置→测试与检查执行后台测试;如果未收到,复制提醒诊断继续排查。 + +没有服务器管理会话、SSH凭据或飞书授权密码,未部署线上服务、构建Docker镜像或验证真实收件。中文部署说明及配置模板已提供,HTTPS、代理、SMTP及收件仍需在1Panel配置核对。SQLite去重元数据保留7天,须保留卷及稳定密钥;成功表示SMTP接受,不表示邮件已进收件箱。 + +截图位于dist/screenshots/tpschedule-060-{week,profile,reminder-check,feedback-problem,feedback-development}.png。测试及原生日志位于本地.tools/tpschedule-060-*,不随源码分发。 diff --git a/mobile/android/app/src/main/kotlin/com/tpschedule/app/MainActivity.kt b/mobile/android/app/src/main/kotlin/com/tpschedule/app/MainActivity.kt index 0924c30..f0cc047 100644 --- a/mobile/android/app/src/main/kotlin/com/tpschedule/app/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/com/tpschedule/app/MainActivity.kt @@ -7,6 +7,7 @@ import io.flutter.embedding.engine.FlutterEngine class MainActivity : FlutterActivity() { private var calendar: CalendarBridge? = null + private var reminders: ReminderSystemBridge? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -18,6 +19,7 @@ class MainActivity : FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) calendar = CalendarBridge(this, flutterEngine.dartExecutor.binaryMessenger) + reminders = ReminderSystemBridge(this, flutterEngine.dartExecutor.binaryMessenger) } override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) @@ -25,6 +27,7 @@ class MainActivity : FlutterActivity() { } override fun onDestroy() { calendar?.close() + reminders?.close() super.onDestroy() } } diff --git a/mobile/android/app/src/main/kotlin/com/tpschedule/app/ReminderSystemBridge.kt b/mobile/android/app/src/main/kotlin/com/tpschedule/app/ReminderSystemBridge.kt new file mode 100644 index 0000000..9f63167 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/com/tpschedule/app/ReminderSystemBridge.kt @@ -0,0 +1,85 @@ +package com.tpschedule.app + +import android.app.Activity +import android.app.ActivityManager +import android.app.NotificationManager +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodChannel + +class ReminderSystemBridge(private val activity: Activity, messenger: BinaryMessenger) { + private val channel = MethodChannel(messenger, "com.tpschedule.app/reminders") + + init { + channel.setMethodCallHandler { call, result -> + try { + when (call.method) { + "getStatus" -> result.success(status()) + "openNotificationSettings" -> { + openNotificationSettings() + result.success(null) + } + "openBackgroundSettings" -> { + openBackgroundSettings() + result.success(null) + } + else -> result.notImplemented() + } + } catch (_: Exception) { + result.error("reminder_system_unavailable", "系统提醒设置暂时不可用", null) + } + } + } + + private fun status(): Map { + val activityManager = activity.getSystemService(Activity.ACTIVITY_SERVICE) as ActivityManager + val powerManager = activity.getSystemService(Activity.POWER_SERVICE) as PowerManager + val notificationManager = activity.getSystemService(Activity.NOTIFICATION_SERVICE) as NotificationManager + return mapOf( + "manufacturer" to Build.MANUFACTURER, + "model" to Build.MODEL, + "androidVersion" to Build.VERSION.RELEASE, + "sdk" to Build.VERSION.SDK_INT, + "backgroundRestricted" to if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + activityManager.isBackgroundRestricted + } else null, + "batteryOptimizationsIgnored" to powerManager.isIgnoringBatteryOptimizations(activity.packageName), + "powerSaveMode" to powerManager.isPowerSaveMode, + "interruptionFilter" to notificationManager.currentInterruptionFilter, + ) + } + + private fun applicationDetails() = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:${activity.packageName}"), + ) + + private fun openNotificationSettings() { + val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) + .putExtra(Settings.EXTRA_APP_PACKAGE, activity.packageName) + } else applicationDetails() + openWithFallback(intent) + } + + private fun openBackgroundSettings() { + // Show settings for the user to review; never request automatic exemption. + openWithFallback(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + } + + private fun openWithFallback(intent: Intent) { + try { + activity.startActivity(intent) + } catch (_: Exception) { + activity.startActivity(applicationDetails()) + } + } + + fun close() { + channel.setMethodCallHandler(null) + } +} diff --git a/mobile/lib/app/app_controller.dart b/mobile/lib/app/app_controller.dart index f05c4cf..d17938f 100644 --- a/mobile/lib/app/app_controller.dart +++ b/mobile/lib/app/app_controller.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import '../domain/app_snapshot.dart'; import '../domain/app_settings.dart'; @@ -24,6 +25,7 @@ class AppController extends ChangeNotifier { AppSettings? _demoSettings; Future _queue = Future.value(); String? lastError, calendarSyncError; + String? reminderErrorCode; ReminderResult reminderResult = const ReminderResult(ReminderStatus.off); List get visibleCourses => isDemo ? _demo : snapshot.courses; AppSettings get visibleSettings => @@ -331,10 +333,18 @@ class AppController extends ChangeNotifier { settings, clock(), ); - } catch (_) { - reminderResult = const ReminderResult( + reminderErrorCode = null; + } catch (error) { + reminderErrorCode = error is ReminderSetupException + ? error.code + : error is PlatformException + ? error.code + : error.runtimeType.toString(); + reminderResult = ReminderResult( ReminderStatus.error, - message: '提醒设置未完成,可重试', + message: error is ReminderSetupException + ? error.message + : '提醒设置未完成,请点击“检查提醒”后重试。', ); } if (snapshot.calendarCleanupPending) { diff --git a/mobile/lib/app/app_version.dart b/mobile/lib/app/app_version.dart new file mode 100644 index 0000000..c238c39 --- /dev/null +++ b/mobile/lib/app/app_version.dart @@ -0,0 +1,2 @@ +const appVersion = '0.6.0'; +const appBuildNumber = 15; diff --git a/mobile/lib/app/hepan_app.dart b/mobile/lib/app/hepan_app.dart index fdab182..f71f237 100644 --- a/mobile/lib/app/hepan_app.dart +++ b/mobile/lib/app/hepan_app.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import '../domain/schedule_engine.dart'; +import '../features/feedback/feedback_http_sender.dart'; +import '../features/feedback/feedback_submission.dart'; import '../features/timetable/course_details.dart'; import 'app_controller.dart'; @@ -20,11 +22,13 @@ class HepanApp extends StatelessWidget { this.schoolBuilder, this.school, this.notificationSelection, + this.feedbackSender, }); final AppController controller; final Widget Function()? schoolBuilder; final SchoolSession? school; final ValueNotifier? notificationSelection; + final FeedbackSender? feedbackSender; @override Widget build(BuildContext context) => MaterialApp( debugShowCheckedModeBanner: false, @@ -38,6 +42,7 @@ class HepanApp extends StatelessWidget { schoolBuilder: schoolBuilder, school: school, notificationSelection: notificationSelection, + feedbackSender: feedbackSender ?? configuredFeedbackSender(), ), ); } @@ -48,11 +53,13 @@ class _Shell extends StatefulWidget { this.schoolBuilder, this.school, this.notificationSelection, + required this.feedbackSender, }); final AppController controller; final Widget Function()? schoolBuilder; final SchoolSession? school; final ValueNotifier? notificationSelection; + final FeedbackSender feedbackSender; @override State<_Shell> createState() => _ShellState(); } @@ -267,6 +274,7 @@ class _ShellState extends State<_Shell> with WidgetsBindingObserver { ), ProfilePage( controller: widget.controller, + feedbackSender: widget.feedbackSender, onLogin: _school, onUpdateTerm: () => _school(chooseTerm: true), authStatus: widget.school?.statusLabel ?? '未登录', diff --git a/mobile/lib/features/feedback/feedback_http_sender.dart b/mobile/lib/features/feedback/feedback_http_sender.dart new file mode 100644 index 0000000..757484e --- /dev/null +++ b/mobile/lib/features/feedback/feedback_http_sender.dart @@ -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 body; +} + +abstract interface class FeedbackHttpTransport { + Future post({ + required Uri endpoint, + required Map headers, + required Uint8List body, + required Duration timeout, + }); +} + +class IoFeedbackHttpTransport implements FeedbackHttpTransport { + const IoFeedbackHttpTransport(); + @override + Future post({ + required Uri endpoint, + required Map headers, + required Uint8List body, + required Duration timeout, + }) async { + final client = HttpClient()..connectionTimeout = timeout; + Future 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 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? _readResult(List body) { + try { + final result = jsonDecode(utf8.decode(body)); + return result is Map ? 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); +} diff --git a/mobile/lib/features/feedback/feedback_page.dart b/mobile/lib/features/feedback/feedback_page.dart new file mode 100644 index 0000000..a764aec --- /dev/null +++ b/mobile/lib/features/feedback/feedback_page.dart @@ -0,0 +1,431 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; + +import '../../app/app_theme.dart'; +import 'feedback_submission.dart'; + +typedef FeedbackAttachmentPicker = Future Function(); + +class FeedbackPage extends StatefulWidget { + const FeedbackPage({ + super.key, + this.sender = const UnconfiguredFeedbackSender(), + this.pickAttachment, + this.limits = FeedbackLimits.defaults, + }); + final FeedbackSender sender; + final FeedbackAttachmentPicker? pickAttachment; + final FeedbackLimits limits; + + @override + State createState() => _FeedbackPageState(); +} + +class _FeedbackPageState extends State { + final _form = GlobalKey(); + final _message = TextEditingController(); + final _contact = TextEditingController(); + final _school = TextEditingController(); + final _attachments = []; + var _kind = FeedbackKind.problem; + var _submitting = false; + var _picking = false; + var _success = false; + var _uncertain = false; + String? _error; + FeedbackSubmission? _pendingSubmission; + + @override + void dispose() { + _message.dispose(); + _contact.dispose(); + _school.dispose(); + super.dispose(); + } + + void _changed([String? value]) { + setState(() { + _pendingSubmission = null; + _error = null; + }); + } + + Future _addAttachment() async { + if (_picking || _submitting) return; + if (_attachments.length >= widget.limits.maxAttachments) { + setState(() => _error = '最多添加 ${widget.limits.maxAttachments} 个附件'); + return; + } + setState(() => _picking = true); + try { + FeedbackAttachment? attachment; + if (widget.pickAttachment != null) { + attachment = await widget.pickAttachment!(); + } else { + final file = await FilePicker.pickFile( + type: FileType.custom, + allowedExtensions: FeedbackAttachment.supportedTypes.keys.toList(), + ); + if (file != null) { + final knownSize = file.lengthSync(); + if (knownSize != null && knownSize > widget.limits.maxFileBytes) { + throw FeedbackException( + '单个附件不能超过 ${_mb(widget.limits.maxFileBytes)} MB', + ); + } + attachment = await FeedbackAttachment.read( + name: file.name, + stream: file.readAsByteStream(), + maxBytes: widget.limits.maxFileBytes, + ); + } + } + if (!mounted || attachment == null) return; + FeedbackSubmission.validateAttachments([ + ..._attachments, + attachment, + ], widget.limits); + setState(() { + _attachments.add(attachment!); + _pendingSubmission = null; + _error = null; + }); + } on FeedbackException catch (error) { + if (mounted) setState(() => _error = error.message); + } catch (_) { + if (mounted) setState(() => _error = '无法读取附件,请重新选择'); + } finally { + if (mounted) setState(() => _picking = false); + } + } + + Future _submit() async { + if (_submitting || _picking) return; + if (!_uncertain && !(_form.currentState?.validate() ?? false)) return; + FocusScope.of(context).unfocus(); + try { + _pendingSubmission ??= _kind == FeedbackKind.problem + ? FeedbackSubmission.problem( + id: FeedbackSubmission.newId(), + message: _message.text, + contact: _contact.text, + attachments: _attachments, + limits: widget.limits, + ) + : FeedbackSubmission.development( + id: FeedbackSubmission.newId(), + school: _school.text, + contact: _contact.text, + ); + setState(() { + _submitting = true; + _error = null; + }); + await widget.sender.send(_pendingSubmission!); + if (mounted) setState(() => _success = true); + } on FeedbackDeliveryUncertain catch (error) { + if (mounted) { + setState(() { + _uncertain = true; + _error = error.message; + }); + } + } on FeedbackException catch (error) { + if (mounted) setState(() => _error = error.message); + } catch (_) { + if (mounted) setState(() => _error = '提交未完成,请检查网络后重试。填写内容和附件已保留。'); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) => PopScope( + canPop: !_submitting, + child: Scaffold( + appBar: AppBar(title: const Text('反馈与共建')), + body: _success + ? _successView(context) + : _uncertain + ? _uncertainView() + : Form( + key: _form, + child: ListView( + padding: const EdgeInsets.all(22), + children: [ + SegmentedButton( + segments: const [ + ButtonSegment( + value: FeedbackKind.problem, + label: Text('反馈问题和建议'), + ), + ButtonSegment( + value: FeedbackKind.development, + label: Text('我要参与开发'), + ), + ], + selected: {_kind}, + showSelectedIcon: false, + onSelectionChanged: _submitting + ? null + : (value) { + setState(() { + _kind = value.single; + _pendingSubmission = null; + _error = null; + }); + _form.currentState?.reset(); + }, + ), + const SizedBox(height: 18), + SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _kind == FeedbackKind.problem + ? '告诉我们哪里需要改进' + : '一起让课表变得更好', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + _kind == FeedbackKind.problem + ? '请描述遇到的问题或你的建议。联系方式和图片、视频都可以选填。' + : '请留下你的学校和联系方式,方便开发者与你联系。', + style: TextStyle( + fontSize: 12, + height: 1.7, + color: ink.withValues(alpha: 0.6), + ), + ), + const SizedBox(height: 20), + if (_kind == FeedbackKind.problem) + TextFormField( + key: const Key('feedback-message'), + controller: _message, + enabled: !_submitting, + minLines: 4, + maxLines: 8, + maxLength: 5000, + decoration: const InputDecoration( + labelText: '问题或建议(必填)', + alignLabelWithHint: true, + ), + onChanged: _changed, + validator: (value) => + (value?.trim().isEmpty ?? true) + ? '请填写问题或建议' + : null, + ) + else + TextFormField( + key: const Key('feedback-school'), + controller: _school, + enabled: !_submitting, + maxLength: 200, + decoration: const InputDecoration( + labelText: '学校(必填)', + ), + onChanged: _changed, + validator: (value) => + (value?.trim().isEmpty ?? true) + ? '请填写学校' + : null, + ), + const SizedBox(height: 14), + TextFormField( + key: const Key('feedback-contact'), + controller: _contact, + enabled: !_submitting, + maxLength: 200, + decoration: InputDecoration( + labelText: _kind == FeedbackKind.problem + ? '联系方式(选填)' + : '联系方式(必填)', + hintText: '微信、QQ、邮箱等', + ), + onChanged: _changed, + validator: (value) => + _kind == FeedbackKind.development && + (value?.trim().isEmpty ?? true) + ? '请填写联系方式' + : null, + ), + if (_kind == FeedbackKind.problem) ...[ + const SizedBox(height: 10), + const Text( + '图片或视频(选填)', + style: TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 6), + Text( + '最多 ${widget.limits.maxAttachments} 个,每个 ${_mb(widget.limits.maxFileBytes)} MB,合计 ${_mb(widget.limits.maxTotalBytes)} MB', + style: TextStyle( + fontSize: 11, + color: ink.withValues(alpha: 0.55), + ), + ), + for (var i = 0; i < _attachments.length; i++) + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + _attachments[i].contentType.startsWith('image/') + ? Icons.image_outlined + : Icons.videocam_outlined, + ), + title: Text( + _attachments[i].filename, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${(_attachments[i].size / 1024).toStringAsFixed(1)} KB', + ), + trailing: IconButton( + tooltip: '移除 ${_attachments[i].filename}', + onPressed: _submitting + ? null + : () => setState(() { + _attachments.removeAt(i); + _pendingSubmission = null; + _error = null; + }), + icon: const Icon(Icons.close_rounded), + ), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + key: const Key('feedback-add-media'), + onPressed: _submitting || _picking + ? null + : _addAttachment, + icon: const Icon(Icons.attach_file_rounded), + label: Text(_picking ? '正在读取附件…' : '添加图片或视频'), + ), + ], + ], + ), + ), + const SizedBox(height: 16), + if (_error != null) ...[ + Text( + _error!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + semanticsLabel: _error, + ), + const SizedBox(height: 14), + ], + FilledButton( + key: const Key('feedback-submit'), + onPressed: _submitting || _picking ? null : _submit, + child: _submitting + ? const Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + SizedBox(width: 10), + Text('正在提交…'), + ], + ) + : const Text('提交'), + ), + const SizedBox(height: 12), + Text( + '点击提交后,仅发送你在当前类型填写的内容和选择的附件。', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11, + height: 1.6, + color: ink.withValues(alpha: 0.5), + ), + ), + ], + ), + ), + ), + ); + + Widget _uncertainView() => ListView( + padding: const EdgeInsets.all(28), + children: [ + const Icon(Icons.mail_outline_rounded, color: river, size: 54), + const SizedBox(height: 18), + const Text( + '提交状态待确认', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 14), + Text( + _error ?? FeedbackDeliveryUncertain.instructions, + style: const TextStyle(height: 1.7), + ), + const SizedBox(height: 20), + const Text('提交编号(可长按复制)'), + const SizedBox(height: 8), + SelectableText( + _pendingSubmission!.id, + key: const Key('feedback-uncertain-id'), + ), + const SizedBox(height: 20), + FilledButton( + key: const Key('feedback-check-original'), + onPressed: _submitting ? null : _submit, + child: Text(_submitting ? '正在检查…' : '检查原提交状态'), + ), + const SizedBox(height: 10), + const Text( + '检查会使用原提交编号和原内容。核对完成前,退出后也请勿创建相同内容的新反馈。', + style: TextStyle(fontSize: 12, height: 1.6), + ), + ], + ); + + Widget _successView(BuildContext context) => Center( + child: Padding( + padding: const EdgeInsets.all(28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.check_circle_outline_rounded, + color: river, + size: 54, + ), + const SizedBox(height: 18), + const Text( + '提交成功', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 10), + Text( + _kind == FeedbackKind.problem + ? '感谢你的反馈,我们已收到。' + : '感谢你愿意参与,我们已收到你的信息。', + textAlign: TextAlign.center, + ), + const SizedBox(height: 22), + FilledButton( + onPressed: () => Navigator.of(context).maybePop(), + child: const Text('完成'), + ), + ], + ), + ), + ); + + String _mb(int bytes) => (bytes / (1024 * 1024)).toStringAsFixed(0); +} diff --git a/mobile/lib/features/feedback/feedback_submission.dart b/mobile/lib/features/feedback/feedback_submission.dart new file mode 100644 index 0000000..51210aa --- /dev/null +++ b/mobile/lib/features/feedback/feedback_submission.dart @@ -0,0 +1,190 @@ +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 [], + }); + + factory FeedbackSubmission.problem({ + required String id, + required String message, + String? contact, + List 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 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 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); diff --git a/mobile/lib/features/profile/profile_page.dart b/mobile/lib/features/profile/profile_page.dart index ccb902e..648edb2 100644 --- a/mobile/lib/features/profile/profile_page.dart +++ b/mobile/lib/features/profile/profile_page.dart @@ -6,7 +6,10 @@ import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; import '../../app/app_theme.dart'; +import '../../app/app_version.dart'; import '../../data/schedule_file_import.dart'; +import '../feedback/feedback_page.dart'; +import '../feedback/feedback_submission.dart'; import 'settings_page.dart'; import 'import_preview.dart'; @@ -19,12 +22,14 @@ class ProfilePage extends StatelessWidget { this.onLogout, this.logoutLabel = '退出学校登录', this.onUpdateTerm, + this.feedbackSender = const UnconfiguredFeedbackSender(), }); final AppController controller; final VoidCallback onLogin; final VoidCallback? onLogout; final VoidCallback? onUpdateTerm; final String authStatus, logoutLabel; + final FeedbackSender feedbackSender; Future _import(BuildContext context) async { try { final file = await FilePicker.pickFile( @@ -212,6 +217,18 @@ class ProfilePage extends StatelessWidget { '无需登录,预览课表样式', controller.showDemo, ), + Divider(height: 1, color: ink.withValues(alpha: 0.06)), + _tile( + Icons.chat_bubble_outline_rounded, + '反馈与共建', + '反馈问题和建议 · 我要参与开发', + () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => FeedbackPage(sender: feedbackSender), + ), + ), + ), ], ), ), @@ -224,6 +241,14 @@ class ProfilePage extends StatelessWidget { style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600), ), const SizedBox(height: 6), + Text( + '版本 $appVersion', + style: TextStyle( + fontSize: 11, + color: ink.withValues(alpha: 0.45), + ), + ), + const SizedBox(height: 6), Text( '今天也有值得期待的小事。', style: TextStyle( diff --git a/mobile/lib/features/profile/reminder_tools.dart b/mobile/lib/features/profile/reminder_tools.dart new file mode 100644 index 0000000..2e95cbc --- /dev/null +++ b/mobile/lib/features/profile/reminder_tools.dart @@ -0,0 +1,232 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../app/app_controller.dart'; +import '../../app/app_theme.dart'; +import '../../app/app_version.dart' as release; +import '../../services/notification_service.dart'; + +class ReminderTools extends StatefulWidget { + const ReminderTools({super.key, required this.controller}); + + final AppController controller; + + @override + State createState() => _ReminderToolsState(); +} + +class _ReminderToolsState extends State { + bool _busy = false; + Map? _diagnosis; + String? _report; + + void _notice(String text) => + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); + + Future _run(Future Function() action) async { + if (_busy) return; + setState(() => _busy = true); + try { + await action(); + } catch (error) { + if (mounted) { + _notice( + error is ReminderSetupException ? error.message : '操作未完成,请检查系统设置后重试', + ); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _test(bool scheduled) => _run(() async { + final message = await widget.controller.notifications.sendTestReminder( + scheduled: scheduled, + ); + if (mounted) _notice(message); + }); + + Future _check() => _run(() async { + await widget.controller.refreshServices(); + final diagnosis = await widget.controller.notifications.diagnose(); + if (!mounted) return; + final c = widget.controller; + final s = c.snapshot.settings; + final rawSystem = diagnosis['system']; + final system = rawSystem is Map ? rawSystem : const {}; + // Copy an explicit allowlist, never raw platform errors, courses or account. + final report = { + 'appVersion': '${release.appVersion}+${release.appBuildNumber}', + 'checkedAt': c.clock().toIso8601String(), + 'remindersEnabled': s.remindersEnabled, + 'semesterStart': s.semester?.startDate.toIso8601String(), + 'semesterWeeks': s.semester?.totalWeeks, + 'reminderOffsets': s.reminderOffsets, + 'courseCount': c.snapshot.courses.length, + 'scheduledCount': c.reminderResult.scheduledCount, + 'reminderStatus': c.reminderResult.status.name, + for (final key in [ + 'notifications', + 'exactAlarm', + 'pendingCount', + 'channelImportance', + 'channelSound', + ]) + key: _scalar(diagnosis[key]), + 'activeQueryWarning': diagnosis['activeQueryWarning'] != null, + 'errorCode': _errorCode(c.reminderErrorCode), + 'systemError': _errorCode(diagnosis['systemError']), + 'system': { + for (final key in [ + 'manufacturer', + 'model', + 'androidVersion', + 'sdk', + 'backgroundRestricted', + 'batteryOptimizationsIgnored', + 'powerSaveMode', + 'interruptionFilter', + ]) + key: _scalar(system[key]), + }, + }; + setState(() { + _diagnosis = diagnosis; + _report = const JsonEncoder.withIndent(' ').convert(report); + }); + }); + + Object? _scalar(Object? value) => + value is String || value is bool || value is num ? value : null; + + String _errorCode(Object? value) { + if (value is String && RegExp(r'^[a-zA-Z0-9_]{1,64}$').hasMatch(value)) { + return value; + } + return value == null ? 'none' : 'unknown'; + } + + Future _copy() => _run(() async { + final report = _report; + if (report == null) return; + await Clipboard.setData(ClipboardData(text: report)); + if (mounted) _notice('提醒诊断已复制,可粘贴到意见反馈中'); + }); + + String _permission(Object? value) => switch (value) { + true => '已允许', + false => '未允许', + _ => '暂时无法确认', + }; + + List _summary(Map diagnosis) { + final rawSystem = diagnosis['system']; + final system = rawSystem is Map ? rawSystem : const {}; + final pending = diagnosis['pendingCount']; + return [ + '通知权限:${_permission(diagnosis['notifications'])}', + '精确提醒:${_permission(diagnosis['exactAlarm'])}', + if (pending is int) '待发送提醒:$pending 条', + if (diagnosis['notifications'] == false) '系统通知已关闭,请在通知设置中开启。', + if (diagnosis['channelImportance'] == 0) '提醒通道已关闭,请在通知设置中开启。', + if (diagnosis['channelSound'] == false) '提醒通道处于静音,请在通知设置中检查声音。', + if (diagnosis['exactAlarm'] == false) '精确提醒未获允许,系统发送时间可能延迟。', + if (system['backgroundRestricted'] == true || + system['powerSaveMode'] == true) + '系统正在限制后台运行,可在后台运行设置中检查。', + if (system['interruptionFilter'] is int && + system['interruptionFilter'] != 0 && + system['interruptionFilter'] != 1) + '勿扰模式可能影响提醒声音。', + if (pending == 0 && widget.controller.snapshot.settings.remindersEnabled) + '当前没有待发送提醒,请核对学期、课程日期和提醒提前时间。', + if (diagnosis['activeQueryWarning'] != null) '部分系统提醒状态暂时无法读取,可稍后再检查。', + if (diagnosis['systemError'] != null) '设备后台状态暂时无法读取,可在系统设置中检查。', + ]; + } + + @override + Widget build(BuildContext context) => SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '测试与检查', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + TextButton( + onPressed: _busy ? null : () => _test(false), + child: const Text('立即测试提醒'), + ), + TextButton( + onPressed: _busy ? null : () => _test(true), + child: const Text('10 秒后台测试'), + ), + ], + ), + const Text( + '后台测试:点击后按 Home 键回到桌面,等待约 10 秒;请勿强行停止应用。', + style: TextStyle(fontSize: 12, height: 1.6, color: river), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + TextButton( + onPressed: _busy ? null : _check, + child: const Text('检查提醒'), + ), + TextButton( + onPressed: _busy + ? null + : () => _run( + () => widget.controller.notifications.openSettings( + background: false, + ), + ), + child: const Text('通知设置'), + ), + TextButton( + onPressed: _busy + ? null + : () => _run( + () => widget.controller.notifications.openSettings( + background: true, + ), + ), + child: const Text('后台运行设置'), + ), + ], + ), + if (_busy) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: LinearProgressIndicator(), + ), + if (_diagnosis != null) ...[ + const SizedBox(height: 8), + for (final line in _summary(_diagnosis!)) + Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Text( + line, + style: const TextStyle(fontSize: 12, height: 1.5), + ), + ), + TextButton( + onPressed: _busy ? null : _copy, + child: const Text('复制提醒诊断'), + ), + ], + ], + ), + ); +} diff --git a/mobile/lib/features/profile/settings_page.dart b/mobile/lib/features/profile/settings_page.dart index 6ccfad2..2a4bdf4 100644 --- a/mobile/lib/features/profile/settings_page.dart +++ b/mobile/lib/features/profile/settings_page.dart @@ -6,6 +6,7 @@ import '../../app/app_theme.dart'; import '../../domain/app_settings.dart'; import '../../domain/semester.dart'; import '../../services/notification_service.dart'; +import 'reminder_tools.dart'; class SettingsPage extends StatefulWidget { const SettingsPage({ @@ -373,6 +374,8 @@ class _SettingsPageState extends State { ), ), const SizedBox(height: 22), + if (!c.isDemo) ReminderTools(controller: c), + if (!c.isDemo) const SizedBox(height: 22), _heading('手机日历'), SurfaceCard( child: Column( diff --git a/mobile/lib/features/timetable/timetable_page.dart b/mobile/lib/features/timetable/timetable_page.dart index 385c659..d0a68ae 100644 --- a/mobile/lib/features/timetable/timetable_page.dart +++ b/mobile/lib/features/timetable/timetable_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; import '../../app/app_theme.dart'; import '../../domain/schedule_engine.dart'; +import '../../widgets/directional_week_transition.dart'; import '../profile/settings_page.dart'; import 'week_grid.dart'; @@ -170,14 +171,18 @@ class _TimetablePageState extends State { ), ), if (term != null) ...[ - WeekGrid( - events: ScheduleEngine.occurrences( - c.visibleCourses, - term, - ).where((e) => e.week == week).toList(), - monday: monday, - today: today, - settings: c.visibleSettings, + DirectionalWeekTransition( + key: ValueKey((c.isDemo, term.firstMonday, term.totalWeeks)), + week: week, + child: WeekGrid( + events: ScheduleEngine.occurrences( + c.visibleCourses, + term, + ).where((e) => e.week == week).toList(), + monday: monday, + today: today, + settings: c.visibleSettings, + ), ), const SizedBox(height: 12), Text( diff --git a/mobile/lib/services/local_notification_service.dart b/mobile/lib/services/local_notification_service.dart index 83c83f9..c2f1385 100644 --- a/mobile/lib/services/local_notification_service.dart +++ b/mobile/lib/services/local_notification_service.dart @@ -3,6 +3,7 @@ import 'package:timezone/timezone.dart' as tz; import 'dart:convert'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter/services.dart'; import '../domain/course.dart'; import '../domain/app_settings.dart'; @@ -32,6 +33,11 @@ class ReminderRequest { abstract class ReminderGateway { Future permissions({required bool request}); Future> pendingIds(); + Future> ownedIds() => pendingIds(); + Future sendTestReminder({required bool scheduled}) async => + '当前环境不支持通知测试'; + Future> diagnose() async => {}; + Future openSettings({required bool background}) async {} Future schedule(ReminderRequest value); Future cancel(int id); } @@ -116,7 +122,9 @@ class LocalNotificationService extends NotificationService { ); } final pending = await gateway.pendingIds(); - for (final old in pending.difference(desired.keys.toSet())) { + for (final old in (await gateway.ownedIds()).difference( + desired.keys.toSet(), + )) { await gateway.cancel(old); _fingerprints.remove(old); } @@ -132,6 +140,13 @@ class LocalNotificationService extends NotificationService { await gateway.schedule(request); _fingerprints[entry.key] = fingerprint; } + final saved = await gateway.pendingIds(); + if (!saved.containsAll(desired.keys)) { + throw const ReminderSetupException( + 'schedule_not_saved', + '系统未保存全部提醒,请点击“检查提醒”后重试。', + ); + } final message = [ if (desired.isEmpty) '当前没有待提醒的课程,请核对学期日期和上课周次。', if (desired.isNotEmpty) '已安排 ${desired.length} 次课前提醒。', @@ -155,17 +170,28 @@ class LocalNotificationService extends NotificationService { @override Future cancelOwned() async { - for (final id in await gateway.pendingIds()) { + for (final id in await gateway.ownedIds()) { await gateway.cancel(id); } _fingerprints.clear(); } + + @override + Future sendTestReminder({required bool scheduled}) => + gateway.sendTestReminder(scheduled: scheduled); + @override + Future> diagnose() => gateway.diagnose(); + @override + Future openSettings({required bool background}) => + gateway.openSettings(background: background); } class PluginReminderGateway extends ReminderGateway { PluginReminderGateway({this.onTap}); final void Function(String key)? onTap; final _plugin = FlutterLocalNotificationsPlugin(); + static const _system = MethodChannel('com.tpschedule.app/reminders'); + String? _activeQueryWarning; Future? _initializing; Future initialize() => _initializing ??= Future(() async { try { @@ -224,15 +250,113 @@ class PluginReminderGateway extends ReminderGateway { Future> pendingIds() async { await initialize(); final pending = await _plugin.pendingNotificationRequests(); - final active = await _plugin.getActiveNotifications(); return { for (final item in pending) if (item.payload?.startsWith('hepan:') == true) item.id, - for (final item in active) - if (item.tag == 'hepan-course' && item.id != null) item.id!, }; } + @override + Future> ownedIds() async { + final owned = await pendingIds(); + try { + final active = await _plugin.getActiveNotifications(); + owned.addAll([ + for (final item in active) + if (item.tag == 'hepan-course' && item.id != null) item.id!, + ]); + _activeQueryWarning = null; + } catch (error) { + // Some OEMs deny active notification access. Future alarms must still run. + _activeQueryWarning = error is PlatformException + ? error.code + : error.runtimeType.toString(); + } + return owned; + } + + @override + Future sendTestReminder({required bool scheduled}) async { + final access = await permissions(request: false); + if (!access.notifications) return '请先允许通知和“课前提醒”通知类别。'; + if (scheduled && !access.exactAlarm) return '请先允许“闹钟与提醒”,再测试后台通知。'; + const details = NotificationDetails( + android: AndroidNotificationDetails( + 'hepan_lessons', + '课前提醒', + channelDescription: '课程开始前的提醒', + importance: Importance.high, + priority: Priority.high, + icon: 'ic_stat_calendar', + tag: 'hepan-test', + ), + ); + if (scheduled) { + await _plugin.cancel(id: -1002, tag: 'hepan-test'); + await _plugin.zonedSchedule( + id: -1002, + title: 'TPSchedule 后台提醒测试', + body: '后台定时提醒已送达。', + scheduledDate: tz.TZDateTime.now(tz.getLocation('Asia/Shanghai')) + .add(const Duration(seconds: 10)), + payload: 'hepan-test:scheduled', + notificationDetails: details, + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + ); + final saved = await _plugin.pendingNotificationRequests(); + if (!saved.any((item) => item.id == -1002)) { + throw const ReminderSetupException('test_not_saved', '系统未保存测试提醒。'); + } + return '已安排测试,请按手机 Home 键回到桌面,10 秒后查看通知栏。'; + } + await _plugin.show( + id: -1001, + title: 'TPSchedule 通知测试', + body: '立即通知已发送,请查看通知栏。', + notificationDetails: details, + payload: 'hepan-test:immediate', + ); + return '已发送测试,请查看手机通知栏。'; + } + + @override + Future> diagnose() async { + final access = await permissions(request: false); + final pending = await pendingIds(); + final channels = await _android?.getNotificationChannels(); + final channel = channels + ?.where((item) => item.id == 'hepan_lessons') + .firstOrNull; + Map? system; + String? systemError; + try { + system = await _system.invokeMapMethod('getStatus'); + } catch (error) { + systemError = error is PlatformException + ? error.code + : error.runtimeType.toString(); + } + return { + 'notifications': access.notifications, + 'exactAlarm': access.exactAlarm, + 'pendingCount': pending.length, + 'channelImportance': channel?.importance.value, + 'channelSound': channel?.playSound, + 'activeQueryWarning': _activeQueryWarning, + 'system': system == null + ? null + : {for (final e in system.entries) '${e.key}': e.value}, + 'systemError': ?systemError, + }; + } + + @override + Future openSettings({required bool background}) async { + await _system.invokeMethod( + background ? 'openBackgroundSettings' : 'openNotificationSettings', + ); + } + @override Future cancel(int id) async { await initialize(); diff --git a/mobile/lib/services/notification_service.dart b/mobile/lib/services/notification_service.dart index 791672e..9beefe7 100644 --- a/mobile/lib/services/notification_service.dart +++ b/mobile/lib/services/notification_service.dart @@ -22,4 +22,13 @@ abstract class NotificationService { ); Future requestPermissions(); Future cancelOwned(); + Future sendTestReminder({required bool scheduled}) async => + '当前环境不支持通知测试'; + Future> diagnose() async => {}; + Future openSettings({required bool background}) async {} +} + +class ReminderSetupException implements Exception { + const ReminderSetupException(this.code, this.message); + final String code, message; } diff --git a/mobile/lib/widgets/directional_week_transition.dart b/mobile/lib/widgets/directional_week_transition.dart new file mode 100644 index 0000000..591e07d --- /dev/null +++ b/mobile/lib/widgets/directional_week_transition.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; + +/// Moves the week body without adding a second horizontal scroll surface. +/// Rapid requests are coalesced to the latest week after the current movement. +class DirectionalWeekTransition extends StatefulWidget { + const DirectionalWeekTransition({ + super.key, + required this.week, + required this.child, + }); + + final int week; + final Widget child; + + @override + State createState() => + _DirectionalWeekTransitionState(); +} + +class _DirectionalWeekTransitionState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _motion = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 280), + value: 1, + )..addStatusListener(_onStatus); + late Widget _incoming; + Widget? _outgoing; + late int _incomingWeek; + double _direction = 1; + bool _reduceMotion = false; + + @override + void initState() { + super.initState(); + _incoming = widget.child; + _incomingWeek = widget.week; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _reduceMotion = MediaQuery.disableAnimationsOf(context); + if (_reduceMotion) _snap(); + } + + void _snap() { + _motion.stop(); + _outgoing = null; + _incoming = widget.child; + _incomingWeek = widget.week; + _motion.value = 1; + } + + void _start() { + _outgoing = _incoming; + _direction = widget.week > _incomingWeek ? 1 : -1; + _incoming = widget.child; + _incomingWeek = widget.week; + _motion.forward(from: 0); + } + + @override + void didUpdateWidget(DirectionalWeekTransition oldWidget) { + super.didUpdateWidget(oldWidget); + if (_reduceMotion) { + _snap(); + } else if (widget.week == _incomingWeek) { + _incoming = widget.child; + } else if (!_motion.isAnimating) { + _start(); + } + // While moving, widget holds the latest request. Do not restart a tween or + // accumulate outgoing grids every time another swipe/arrow arrives. + } + + void _onStatus(AnimationStatus status) { + if (status != AnimationStatus.completed || + !mounted || + _reduceMotion || + _outgoing == null) { + return; + } + setState(() { + _outgoing = null; + if (widget.week != _incomingWeek) _start(); + }); + } + + @override + void dispose() { + _motion.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ClipRect( + child: AnimatedBuilder( + animation: _motion, + builder: (context, _) { + final progress = Curves.easeInOutCubic.transform(_motion.value); + final stale = widget.week != _incomingWeek; + return Stack( + alignment: Alignment.topLeft, + children: [ + if (_outgoing != null) + ExcludeSemantics( + child: ExcludeFocus( + child: IgnorePointer( + child: FractionalTranslation( + key: const Key('week-transition-outgoing'), + translation: Offset(-_direction * 0.08 * progress, 0), + child: Opacity(opacity: 1 - progress, child: _outgoing), + ), + ), + ), + ), + ExcludeSemantics( + excluding: stale, + child: ExcludeFocus( + excluding: stale, + child: IgnorePointer( + ignoring: stale, + child: FractionalTranslation( + key: const Key('week-transition-incoming'), + translation: Offset(_direction * 0.08 * (1 - progress), 0), + child: Opacity(opacity: progress, child: _incoming), + ), + ), + ), + ), + ], + ); + }, + ), + ); +} diff --git a/mobile/lib/widgets/glass_dock.dart b/mobile/lib/widgets/glass_dock.dart index 3cbbf46..de70a20 100644 --- a/mobile/lib/widgets/glass_dock.dart +++ b/mobile/lib/widgets/glass_dock.dart @@ -300,7 +300,7 @@ class _GlassDockState extends State with TickerProviderStateMixin { : _blue.withValues(alpha: 0.72), shadows: [ Shadow( - color: _blue.withValues( + color: Colors.white.withValues( alpha: index == _position.round().clamp(0, 2) ? 0.60 : 0.38, @@ -308,7 +308,7 @@ class _GlassDockState extends State with TickerProviderStateMixin { blurRadius: 8, ), Shadow( - color: const Color(0xFFEAF4FF).withValues( + color: Colors.white.withValues( alpha: index == _position.round().clamp(0, 2) ? 0.95 : 0.85, @@ -316,7 +316,7 @@ class _GlassDockState extends State with TickerProviderStateMixin { blurRadius: 2.8, ), Shadow( - color: _blue.withValues( + color: Colors.white.withValues( alpha: index == _position.round().clamp(0, 2) ? 0.34 : 0.22, diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 2ef95fa..a836a94 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,7 +1,7 @@ name: hepan_everyday description: TPSchedule 学生课表 publish_to: none -version: 0.5.6+14 +version: 0.6.0+15 environment: sdk: ^3.13.5 dependencies: diff --git a/mobile/test/features/feedback_http_sender_test.dart b/mobile/test/features/feedback_http_sender_test.dart new file mode 100644 index 0000000..6e86b13 --- /dev/null +++ b/mobile/test/features/feedback_http_sender_test.dart @@ -0,0 +1,311 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/features/feedback/feedback_http_sender.dart'; +import 'package:hepan_everyday/features/feedback/feedback_submission.dart'; + +class FakeFeedbackHttpTransport implements FeedbackHttpTransport { + int calls = 0; + Uri? endpoint; + Map? headers; + Uint8List? body; + FeedbackHttpResponse response = FeedbackHttpResponse( + 200, + utf8.encode('{"success":true,"submission_id":"same-id","status":"sent"}'), + ); + Completer? pending; + + @override + Future post({ + required Uri endpoint, + required Map headers, + required Uint8List body, + required Duration timeout, + }) async { + calls++; + this.endpoint = endpoint; + this.headers = headers; + this.body = body; + return pending == null ? response : await pending!.future; + } +} + +void main() { + test( + 'matching uncertain delivery is distinct and never exposes server detail', + () async { + final transport = FakeFeedbackHttpTransport(); + final sender = HttpFeedbackSender( + endpoint: 'https://example.com/v1/feedback', + transport: transport, + ); + final submission = FeedbackSubmission.problem( + id: 'same-id', + message: '问题', + ); + for (final code in ['delivery_uncertain', 'storage_unavailable']) { + transport.response = FeedbackHttpResponse( + 503, + utf8.encode( + jsonEncode({ + 'success': false, + 'error': code, + 'detail': 'private SMTP error', + 'submission_id': 'same-id', + 'status': 'uncertain', + }), + ), + ); + await expectLater( + sender.send(submission), + throwsA( + isA() + .having((error) => error.submissionId, 'original ID', 'same-id') + .having( + (error) => error.message, + 'safe instructions', + allOf( + contains('请勿修改'), + isNot(contains('private SMTP error')), + ), + ), + ), + ); + } + transport.response = FeedbackHttpResponse( + 503, + utf8.encode( + jsonEncode({ + 'success': false, + 'error': 'delivery_uncertain', + 'submission_id': 'other-id', + 'status': 'uncertain', + }), + ), + ); + await expectLater( + sender.send(submission), + throwsA( + allOf( + isA(), + isNot(isA()), + ), + ), + ); + }, + ); + + test( + 'permanent media/validation failures give actionable safe messages', + () async { + final transport = FakeFeedbackHttpTransport(); + final sender = HttpFeedbackSender( + endpoint: 'https://example.com/v1/feedback', + transport: transport, + ); + final submission = FeedbackSubmission.problem( + id: 'same-id', + message: '问题', + ); + for (final rejection in [ + (status: 415, code: 'unsupported_media', expected: '移除附件'), + (status: 422, code: 'validation_error', expected: '必填内容'), + ]) { + transport.response = FeedbackHttpResponse( + rejection.status, + utf8.encode( + jsonEncode({ + 'success': false, + 'error': rejection.code, + 'detail': 'untrusted detail', + }), + ), + ); + await expectLater( + sender.send(submission), + throwsA( + isA().having( + (error) => error.message, + 'actionable message', + allOf( + contains(rejection.expected), + isNot(contains('untrusted detail')), + ), + ), + ), + ); + } + transport.response = FeedbackHttpResponse( + 503, + utf8.encode( + jsonEncode({ + 'success': false, + 'error': 'smtp_unavailable', + 'submission_id': 'same-id', + 'status': 'retryable', + }), + ), + ); + await expectLater( + sender.send(submission), + throwsA( + allOf( + isA().having( + (error) => error.message, + 'safe retry', + contains('原提交'), + ), + isNot(isA()), + ), + ), + ); + }, + ); + + test('requires HTTPS before contacting transport', () async { + final transport = FakeFeedbackHttpTransport(); + final submission = FeedbackSubmission.problem(id: 'same-id', message: '问题'); + for (final endpoint in [ + '', + 'http://example.com/v1/feedback', + 'https://user:pass@example.com/v1/feedback', + ]) { + await expectLater( + HttpFeedbackSender( + endpoint: endpoint, + transport: transport, + ).send(submission), + throwsA(isA()), + ); + } + expect(transport.calls, 0); + }); + + test( + 'problem multipart contains relevant fields and repeated media parts', + () async { + final transport = FakeFeedbackHttpTransport(); + final file = await FeedbackAttachment.read( + name: 'x.png', + stream: Stream.value([1, 2, 3]), + ); + final sender = HttpFeedbackSender( + endpoint: 'https://example.com/v1/feedback', + transport: transport, + ); + await sender.send( + FeedbackSubmission.problem( + id: 'same-id', + message: ' 问题 ', + attachments: [file, file], + ), + ); + final body = utf8.decode(transport.body!, allowMalformed: true); + expect(body, contains('name="submission_id"\r\n\r\nsame-id')); + expect(body, contains('name="type"\r\n\r\nproblem')); + expect(body, contains('name="message"\r\n\r\n问题')); + expect(body, isNot(contains('name="school"'))); + expect(body, isNot(contains('name="contact"'))); + expect('name="attachments"'.allMatches(body), hasLength(2)); + expect(body, contains('Content-Type: image/png')); + expect( + transport.headers!['Content-Type'], + startsWith('multipart/form-data; boundary='), + ); + expect(transport.headers!.keys, isNot(contains('Authorization'))); + }, + ); + + test('development multipart sends school and contact without problem/media fields', () async { + final transport = FakeFeedbackHttpTransport(); + final sender = HttpFeedbackSender( + endpoint: 'https://example.com/v1/feedback', + transport: transport, + ); + await sender.send( + FeedbackSubmission.development( + id: 'same-id', + school: '某大学', + contact: 'QQ 123', + ), + ); + final body = utf8.decode(transport.body!); + expect(body, contains('name="type"\r\n\r\ndevelopment')); + expect(body, contains('name="school"\r\n\r\n某大学')); + expect(body, contains('name="contact"\r\n\r\nQQ 123')); + expect(body, isNot(contains('name="message"'))); + expect(body, isNot(contains('name="attachments"'))); + }); + + test('only matching confirmed sent response is successful', () async { + final transport = FakeFeedbackHttpTransport(); + final sender = HttpFeedbackSender( + endpoint: 'https://example.com/v1/feedback', + transport: transport, + ); + final submission = FeedbackSubmission.problem(id: 'same-id', message: '问题'); + for (final response in [ + FeedbackHttpResponse(200, utf8.encode('{"success":true}')), + FeedbackHttpResponse( + 200, + utf8.encode('{"success":true,"submission_id":"other","status":"sent"}'), + ), + FeedbackHttpResponse( + 200, + utf8.encode( + '{"success":true,"submission_id":"same-id","status":"queued"}', + ), + ), + FeedbackHttpResponse( + 200, + utf8.encode( + '{"success":false,"submission_id":"same-id","status":"sent"}', + ), + ), + FeedbackHttpResponse( + 500, + utf8.encode( + '{"success":true,"submission_id":"same-id","status":"sent"}', + ), + ), + FeedbackHttpResponse(200, utf8.encode('bad JSON')), + FeedbackHttpResponse(200, Uint8List(65537)), + ]) { + transport.response = response; + await expectLater( + sender.send(submission), + throwsA(isA()), + ); + } + }); + + test( + 'timeout exposes safe error and repeated request keeps submission ID', + () async { + final transport = FakeFeedbackHttpTransport() + ..pending = Completer(); + final sender = HttpFeedbackSender( + endpoint: 'https://example.com/v1/feedback', + transport: transport, + timeout: const Duration(milliseconds: 1), + ); + final submission = FeedbackSubmission.problem( + id: 'same-id', + message: '问题', + ); + await expectLater( + sender.send(submission), + throwsA(isA()), + ); + transport.pending = null; + await sender.send(submission); + expect(transport.calls, 2); + expect( + utf8.decode(transport.body!), + contains('name="submission_id"\r\n\r\nsame-id'), + ); + }, + ); +} diff --git a/mobile/test/features/feedback_test.dart b/mobile/test/features/feedback_test.dart new file mode 100644 index 0000000..64570f2 --- /dev/null +++ b/mobile/test/features/feedback_test.dart @@ -0,0 +1,320 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/features/feedback/feedback_page.dart'; +import 'package:hepan_everyday/features/feedback/feedback_submission.dart'; + +class RecordingSender implements FeedbackSender { + final submissions = []; + Completer? pending; + bool fail = false; + bool uncertain = false; + + @override + Future send(FeedbackSubmission submission) async { + submissions.add(submission); + if (pending != null) await pending!.future; + if (uncertain) throw FeedbackDeliveryUncertain(submission.id); + if (fail) throw const FeedbackException('网络暂时不可用,请重试'); + } +} + +Future reveal( + WidgetTester tester, + Finder target, { + bool backwards = false, +}) async { + await tester.scrollUntilVisible( + target, + backwards ? -250 : 250, + scrollable: find.byType(Scrollable).first, + ); + await tester.pump(); +} + +void main() { + testWidgets('uncertain delivery retains original payload for status check', ( + tester, + ) async { + final sender = RecordingSender()..uncertain = true; + await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender))); + await tester.enterText(find.byKey(const Key('feedback-message')), '原来的反馈'); + await reveal(tester, find.byKey(const Key('feedback-submit'))); + await tester.tap(find.byKey(const Key('feedback-submit'))); + await tester.pumpAndSettle(); + expect(find.text('提交成功'), findsNothing); + expect(find.text('提交状态待确认'), findsOneWidget); + expect(find.byKey(const Key('feedback-message')), findsNothing); + expect(find.byKey(const Key('feedback-submit')), findsNothing); + final original = sender.submissions.single; + expect( + tester + .widget( + find.byKey(const Key('feedback-uncertain-id')), + ) + .data, + original.id, + ); + sender.uncertain = false; + await reveal(tester, find.byKey(const Key('feedback-check-original'))); + await tester.tap(find.byKey(const Key('feedback-check-original'))); + await tester.pumpAndSettle(); + expect(sender.submissions, hasLength(2)); + expect(identical(sender.submissions[0], sender.submissions[1]), isTrue); + expect(find.text('提交成功'), findsOneWidget); + }); + + test('submission trims relevant fields and rejects whitespace', () { + expect( + () => FeedbackSubmission.problem(id: 'a', message: ' '), + throwsA(isA()), + ); + final problem = FeedbackSubmission.problem( + id: 'a', + message: ' 页面建议 ', + contact: ' ', + ); + expect(problem.message, '页面建议'); + expect(problem.contact, isNull); + expect(problem.school, isNull); + final application = FeedbackSubmission.development( + id: 'b', + school: ' 某大学 ', + contact: ' 微信 abc ', + ); + expect(application.school, '某大学'); + expect(application.contact, '微信 abc'); + expect(application.message, isNull); + expect(application.attachments, isEmpty); + expect( + () => FeedbackSubmission.development(id: 'b', school: ' ', contact: 'x'), + throwsA(isA()), + ); + expect( + () => FeedbackSubmission.development(id: 'b', school: 'x', contact: ' '), + throwsA(isA()), + ); + }); + + test('attachment read stops at byte limit and sanitizes names', () async { + var reads = 0; + Stream> oversized() async* { + reads++; + yield [1, 2, 3]; + reads++; + yield [4, 5, 6]; + reads++; + yield [7]; + } + + await expectLater( + FeedbackAttachment.read(name: 'x.png', stream: oversized(), maxBytes: 4), + throwsA(isA()), + ); + expect(reads, 2); + final attachment = await FeedbackAttachment.read( + name: '../../unsafe\r\nname.PNG', + stream: Stream.value([1, 2]), + ); + expect(attachment.filename, 'unsafename.PNG'); + expect(attachment.contentType, 'image/png'); + await expectLater( + FeedbackAttachment.read(name: 'x.exe', stream: Stream.value([1])), + throwsA(isA()), + ); + }); + + test('submission enforces attachment count and total limits', () async { + final file = await FeedbackAttachment.read( + name: 'x.jpg', + stream: Stream.value([1]), + ); + expect( + () => FeedbackSubmission.problem( + id: 'a', + message: 'x', + attachments: [file, file, file, file], + ), + throwsA(isA()), + ); + final large = await FeedbackAttachment.read( + name: 'x.mp4', + stream: Stream.value(Uint8List(8 * 1024 * 1024)), + ); + expect( + () => FeedbackSubmission.problem( + id: 'a', + message: 'x', + attachments: [large, large, large], + ), + throwsA(isA()), + ); + }); + + testWidgets('problem requires message; contact is optional', (tester) async { + final sender = RecordingSender(); + await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender))); + await reveal(tester, find.byKey(const Key('feedback-submit'))); + await tester.tap(find.byKey(const Key('feedback-submit'))); + await tester.pumpAndSettle(); + expect(find.text('请填写问题或建议'), findsOneWidget); + expect(sender.submissions, isEmpty); + await reveal( + tester, + find.byKey(const Key('feedback-message')), + backwards: true, + ); + await tester.enterText(find.byKey(const Key('feedback-message')), ' 建议内容 '); + await reveal(tester, find.byKey(const Key('feedback-submit'))); + await tester.tap(find.byKey(const Key('feedback-submit'))); + await tester.pumpAndSettle(); + expect(sender.submissions.single.message, '建议内容'); + expect(sender.submissions.single.contact, isNull); + expect(find.text('提交成功'), findsOneWidget); + }); + + testWidgets('development requires school/contact and excludes hidden data', ( + tester, + ) async { + final sender = RecordingSender(); + await tester.pumpWidget( + MaterialApp( + home: FeedbackPage( + sender: sender, + pickAttachment: () async => FeedbackAttachment.read( + name: 'hidden.png', + stream: Stream.value([1, 2, 3]), + ), + ), + ), + ); + await tester.enterText(find.byKey(const Key('feedback-message')), '旧问题'); + await tester.ensureVisible(find.byKey(const Key('feedback-add-media'))); + await tester.tap(find.byKey(const Key('feedback-add-media'))); + await tester.pumpAndSettle(); + await reveal(tester, find.text('我要参与开发'), backwards: true); + await tester.tap(find.text('我要参与开发')); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('feedback-message')), findsNothing); + expect(find.byKey(const Key('feedback-add-media')), findsNothing); + await reveal(tester, find.byKey(const Key('feedback-submit'))); + await tester.tap(find.byKey(const Key('feedback-submit'))); + await tester.pumpAndSettle(); + expect(find.text('请填写学校'), findsOneWidget); + expect(find.text('请填写联系方式'), findsOneWidget); + await tester.enterText(find.byKey(const Key('feedback-school')), ' 某大学 '); + await tester.enterText( + find.byKey(const Key('feedback-contact')), + ' QQ 123 ', + ); + await reveal(tester, find.byKey(const Key('feedback-submit'))); + await tester.tap(find.byKey(const Key('feedback-submit'))); + await tester.pumpAndSettle(); + final submission = sender.submissions.single; + expect(submission.kind, FeedbackKind.development); + expect(submission.school, '某大学'); + expect(submission.contact, 'QQ 123'); + expect(submission.message, isNull); + expect(submission.attachments, isEmpty); + }); + + testWidgets( + 'pending send prevents duplicates; failure preserves data and retry ID', + (tester) async { + final sender = RecordingSender() + ..pending = Completer() + ..fail = true; + await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender))); + await tester.enterText(find.byKey(const Key('feedback-message')), '问题'); + await reveal(tester, find.byKey(const Key('feedback-submit'))); + await tester.tap(find.byKey(const Key('feedback-submit'))); + await tester.pump(); + await tester.tap(find.byKey(const Key('feedback-submit'))); + await tester.pump(); + expect(sender.submissions, hasLength(1)); + expect(find.text('提交成功'), findsNothing); + sender.pending!.complete(); + await tester.pumpAndSettle(); + expect(find.text('网络暂时不可用,请重试'), findsOneWidget); + expect(find.text('问题'), findsOneWidget); + sender + ..pending = null + ..fail = false; + await reveal(tester, find.byKey(const Key('feedback-submit'))); + await tester.tap(find.byKey(const Key('feedback-submit'))); + await tester.pumpAndSettle(); + expect(sender.submissions, hasLength(2)); + expect(sender.submissions[0].id, sender.submissions[1].id); + expect(find.text('提交成功'), findsOneWidget); + }, + ); + + testWidgets('editing a failed request starts a new submission ID', ( + tester, + ) async { + final sender = RecordingSender()..fail = true; + await tester.pumpWidget(MaterialApp(home: FeedbackPage(sender: sender))); + await tester.enterText(find.byKey(const Key('feedback-message')), '原来的问题'); + await reveal(tester, find.byKey(const Key('feedback-submit'))); + await tester.tap(find.byKey(const Key('feedback-submit'))); + await tester.pumpAndSettle(); + await reveal( + tester, + find.byKey(const Key('feedback-message')), + backwards: true, + ); + await tester.enterText(find.byKey(const Key('feedback-message')), '更新的问题'); + sender.fail = false; + await reveal(tester, find.byKey(const Key('feedback-submit'))); + await tester.tap(find.byKey(const Key('feedback-submit'))); + await tester.pumpAndSettle(); + expect(sender.submissions[1].id, isNot(sender.submissions[0].id)); + expect(sender.submissions[1].message, '更新的问题'); + }); + + testWidgets('unconfigured service gives honest error and preserves input', ( + tester, + ) async { + await tester.pumpWidget(const MaterialApp(home: FeedbackPage())); + await tester.enterText(find.byKey(const Key('feedback-message')), '问题'); + await reveal(tester, find.byKey(const Key('feedback-submit'))); + await tester.tap(find.byKey(const Key('feedback-submit'))); + await tester.pumpAndSettle(); + expect(find.text(UnconfiguredFeedbackSender.message), findsOneWidget); + expect(find.text('提交成功'), findsNothing); + expect(find.text('问题'), findsOneWidget); + }); + + testWidgets('selected media can be removed and survives send failure', ( + tester, + ) async { + final sender = RecordingSender()..fail = true; + await tester.pumpWidget( + MaterialApp( + home: FeedbackPage( + sender: sender, + pickAttachment: () async => FeedbackAttachment.read( + name: 'screen.png', + stream: Stream.value([1, 2, 3]), + ), + ), + ), + ); + await tester.enterText(find.byKey(const Key('feedback-message')), '问题'); + await tester.ensureVisible(find.byKey(const Key('feedback-add-media'))); + await tester.tap(find.byKey(const Key('feedback-add-media'))); + await tester.pumpAndSettle(); + expect(find.text('screen.png'), findsOneWidget); + await reveal(tester, find.byKey(const Key('feedback-submit'))); + await tester.tap(find.byKey(const Key('feedback-submit'))); + await tester.pumpAndSettle(); + expect(sender.submissions.single.attachments, hasLength(1)); + expect(find.text('screen.png'), findsOneWidget); + await tester.ensureVisible(find.byTooltip('移除 screen.png')); + await tester.tap(find.byTooltip('移除 screen.png')); + await tester.pumpAndSettle(); + expect(find.text('screen.png'), findsNothing); + }); +} diff --git a/mobile/test/services/notification_gateway_test.dart b/mobile/test/services/notification_gateway_test.dart index b115495..8912098 100644 --- a/mobile/test/services/notification_gateway_test.dart +++ b/mobile/test/services/notification_gateway_test.dart @@ -1,3 +1,4 @@ +import 'package:timezone/data/latest.dart' as tzdata; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -6,6 +7,129 @@ import 'package:hepan_everyday/services/local_notification_service.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + test( + 'querying future reminders does not depend on active notification access', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + AndroidFlutterLocalNotificationsPlugin.registerWith(); + const channel = MethodChannel( + 'dexterous.com/flutter/local_notifications', + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + if (call.method == 'initialize') return true; + if (call.method == 'getNotificationAppLaunchDetails') { + return {'notificationLaunchedApp': false}; + } + if (call.method == 'pendingNotificationRequests') { + return [ + { + 'id': 123, + 'title': '课程', + 'body': '教室', + 'payload': 'hepan:course/2026-10-14', + }, + ]; + } + if (call.method == 'getActiveNotifications') { + throw PlatformException(code: 'active_query_unavailable'); + } + return null; + }); + addTearDown(() { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + expect(await PluginReminderGateway().pendingIds(), {123}); + }, + ); + test('active lookup failure preserves owned future alarms', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + AndroidFlutterLocalNotificationsPlugin.registerWith(); + const channel = MethodChannel('dexterous.com/flutter/local_notifications'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'initialize') return true; + if (call.method == 'getNotificationAppLaunchDetails') { + return {'notificationLaunchedApp': false}; + } + if (call.method == 'pendingNotificationRequests') { + return [ + {'id': 123, 'title': '', 'body': '', 'payload': 'hepan:course'}, + { + 'id': -1002, + 'title': '', + 'body': '', + 'payload': 'hepan-test:scheduled', + }, + ]; + } + if (call.method == 'getActiveNotifications') { + throw PlatformException(code: 'unavailable'); + } + return null; + }); + addTearDown(() { + debugDefaultTargetPlatformOverride = null; + messenger.setMockMethodCallHandler(channel, null); + }); + expect(await PluginReminderGateway().ownedIds(), {123}); + }); + test( + 'background test uses an exact alarm isolated from course ownership', + () async { + tzdata.initializeTimeZones(); + debugDefaultTargetPlatformOverride = TargetPlatform.android; + AndroidFlutterLocalNotificationsPlugin.registerWith(); + const channel = MethodChannel( + 'dexterous.com/flutter/local_notifications', + ); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + Map? scheduled; + messenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'initialize') return true; + if (call.method == 'getNotificationAppLaunchDetails') { + return {'notificationLaunchedApp': false}; + } + if (call.method == 'areNotificationsEnabled' || + call.method == 'canScheduleExactNotifications') { + return true; + } + if (call.method == 'zonedSchedule') { + scheduled = call.arguments as Map; + return null; + } + if (call.method == 'pendingNotificationRequests') { + return [ + if (scheduled != null) + { + 'id': -1002, + 'title': '', + 'body': '', + 'payload': 'hepan-test:scheduled', + }, + ]; + } + return null; + }); + addTearDown(() { + debugDefaultTargetPlatformOverride = null; + messenger.setMockMethodCallHandler(channel, null); + }); + final gateway = PluginReminderGateway(); + expect(await gateway.sendTestReminder(scheduled: true), contains('10 秒')); + expect(scheduled?['id'], -1002); + expect(scheduled?['payload'], 'hepan-test:scheduled'); + expect( + (scheduled?['platformSpecifics'] as Map?)?['scheduleMode'], + 'exactAllowWhileIdle', + ); + expect(await gateway.pendingIds(), isEmpty); + }, + ); test('transient native initialization failure can be retried', () async { debugDefaultTargetPlatformOverride = TargetPlatform.android; AndroidFlutterLocalNotificationsPlugin.registerWith(); @@ -66,9 +190,9 @@ void main() { 'playSound': true, 'enableVibration': true, 'enableLights': false, - 'bypassDnd': false, - 'ledColor': 0, - 'audioAttributesUsage': 5, + 'bypassDnd': false, + 'ledColor': 0, + 'audioAttributesUsage': 5, }, ]; } diff --git a/mobile/test/services/notification_service_test.dart b/mobile/test/services/notification_service_test.dart index 99d0375..38ca9d5 100644 --- a/mobile/test/services/notification_service_test.dart +++ b/mobile/test/services/notification_service_test.dart @@ -12,6 +12,7 @@ class AlarmGateway extends ReminderGateway { ReminderPermissions permission = const ReminderPermissions(true, true); final alarms = {}; int permissionRequests = 0; + bool dropWrites = false; @override Future permissions({required bool request}) async { if (request) permissionRequests++; @@ -22,7 +23,7 @@ class AlarmGateway extends ReminderGateway { Future> pendingIds() async => alarms.keys.toSet(); @override Future schedule(ReminderRequest value) async { - alarms[value.id] = value; + if (!dropWrites) alarms[value.id] = value; } @override @@ -47,6 +48,23 @@ void main() { return AppSnapshot.fromJson(json).settings; } + test( + 'does not report success when native scheduling did not persist', + () async { + gateway.dropWrites = true; + final events = ScheduleEngine.occurrences([course()], term); + await expectLater( + service.reconcile(events, settings, DateTime.utc(2026, 9, 9)), + throwsA( + isA().having( + (e) => e.code, + 'code', + 'schedule_not_saved', + ), + ), + ); + }, + ); test( 'selected advance times produce independent alarms and survive refresh', () async { diff --git a/mobile/test/widgets/reminder_tools_test.dart b/mobile/test/widgets/reminder_tools_test.dart new file mode 100644 index 0000000..681272f --- /dev/null +++ b/mobile/test/widgets/reminder_tools_test.dart @@ -0,0 +1,142 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/app/app_controller.dart'; +import 'package:hepan_everyday/domain/app_snapshot.dart'; +import 'package:hepan_everyday/domain/app_settings.dart'; +import 'package:hepan_everyday/features/profile/reminder_tools.dart'; + +import '../support/fakes.dart'; + +class _Notifications extends FakeNotifications { + Completer>? diagnosis; + int? reconciliationsAtDiagnosis; + final tests = []; + + @override + Future sendTestReminder({required bool scheduled}) async { + tests.add(scheduled); + return scheduled ? '已安排测试提醒' : '已发送测试提醒'; + } + + @override + Future> diagnose() async { + reconciliationsAtDiagnosis = reconciliations; + return diagnosis?.future ?? + { + 'notifications': true, + 'exactAlarm': false, + 'pendingCount': 2, + 'channelImportance': 4, + 'channelSound': true, + 'system': { + 'manufacturer': 'TestPhone', + 'model': 'TestModel', + 'sdk': 35, + 'backgroundRestricted': true, + 'account': 'private@example.com', + }, + 'courseNames': ['Private course name'], + 'account': 'private@example.com', + 'errorCode': 'none', + }; + } + + @override + Future openSettings({required bool background}) async {} +} + +Future _open(WidgetTester tester, _Notifications notifications) async { + final controller = AppController( + MemoryStore(AppSnapshot(settings: AppSettings())), + notifications, + FakeCalendar(), + () => DateTime.utc(2026, 10, 14), + ); + await controller.initialize(); + addTearDown(controller.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: ReminderTools(controller: controller), + ), + ), + ), + ); +} + +void main() { + testWidgets('test controls distinguish immediate and background reminders', ( + tester, + ) async { + final notifications = _Notifications(); + await _open(tester, notifications); + await tester.tap(find.text('立即测试提醒')); + await tester.pumpAndSettle(); + expect(find.text('已发送测试提醒'), findsOneWidget); + await tester.tap(find.text('10 秒后台测试')); + await tester.pumpAndSettle(); + expect(notifications.tests, [false, true]); + expect(find.textContaining('Home'), findsOneWidget); + }); + + testWidgets('diagnosis refreshes services and prevents overlapping actions', ( + tester, + ) async { + final notifications = _Notifications() + ..diagnosis = Completer>(); + await _open(tester, notifications); + final previous = notifications.reconciliations; + await tester.tap(find.text('检查提醒')); + await tester.pump(); + expect(notifications.reconciliationsAtDiagnosis, previous + 1); + for (final button in tester.widgetList( + find.byType(TextButton), + )) { + expect(button.onPressed, isNull); + } + notifications.diagnosis!.complete({'notifications': false}); + await tester.pumpAndSettle(); + expect(find.textContaining('系统通知已关闭'), findsOneWidget); + expect(find.text('复制提醒诊断'), findsOneWidget); + }); + + testWidgets('copied diagnostics omit unapproved fields and private values', ( + tester, + ) async { + String? copied; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copied = (call.arguments as Map)['text'] as String; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + await _open(tester, _Notifications()); + await tester.tap(find.text('检查提醒')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('复制提醒诊断')); + await tester.tap(find.text('复制提醒诊断')); + await tester.pumpAndSettle(); + expect(copied, isNotNull); + expect(copied, isNot(contains('private@example.com'))); + expect(copied, isNot(contains('Private course name'))); + final report = jsonDecode(copied!) as Map; + expect(report['pendingCount'], 2); + expect(report['courseCount'], 0); + expect(report['appVersion'], isNotEmpty); + expect((report['system'] as Map)['model'], 'TestModel'); + }); +} diff --git a/mobile/test/widgets/week_swipe_test.dart b/mobile/test/widgets/week_swipe_test.dart index de9b398..5a716fc 100644 --- a/mobile/test/widgets/week_swipe_test.dart +++ b/mobile/test/widgets/week_swipe_test.dart @@ -6,6 +6,7 @@ import 'package:hepan_everyday/domain/app_snapshot.dart'; import 'package:hepan_everyday/domain/app_settings.dart'; import 'package:hepan_everyday/domain/schedule_engine.dart'; import 'package:hepan_everyday/features/timetable/timetable_page.dart'; +import 'package:hepan_everyday/widgets/directional_week_transition.dart'; import '../support/fakes.dart'; @@ -51,6 +52,45 @@ Future _open(WidgetTester tester, AppController controller) async { void main() { setUpAll(tzdata.initializeTimeZones); + testWidgets( + 'week arrows animate the grid and current week reverses direction', + (tester) async { + await _open(tester, _controller()); + await tester.tap(find.byIcon(Icons.chevron_right_rounded)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.byType(DirectionalWeekTransition), findsOneWidget); + expect(find.text('第1周课程'), findsOneWidget); + expect(find.text('第2周课程'), findsOneWidget); + expect( + tester + .widget( + find.byKey(const Key('week-transition-incoming')), + ) + .translation + .dx, + greaterThan(0), + ); + await tester.pumpAndSettle(); + expect(find.text('第1周课程'), findsNothing); + await tester.tap(find.text('本周')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect( + tester + .widget( + find.byKey(const Key('week-transition-incoming')), + ) + .translation + .dx, + lessThan(0), + ); + await tester.pumpAndSettle(); + expect(find.text('第 1 周'), findsOneWidget); + expect(find.text('第2周课程'), findsNothing); + expect(find.text('周日'), findsOneWidget); + }, + ); testWidgets( 'horizontal course swipe changes week and course taps still open', (tester) async { diff --git a/mobile/test/widgets/week_transition_test.dart b/mobile/test/widgets/week_transition_test.dart new file mode 100644 index 0000000..e1ad733 --- /dev/null +++ b/mobile/test/widgets/week_transition_test.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hepan_everyday/widgets/directional_week_transition.dart'; + +Widget _week(int week, {bool reduceMotion = false, VoidCallback? onTap}) => + MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: reduceMotion), + child: Scaffold( + body: DirectionalWeekTransition( + week: week, + child: SizedBox( + width: 320, + height: 180, + child: Center( + child: Semantics( + label: 'week-$week-course', + button: true, + excludeSemantics: true, + child: TextButton( + onPressed: onTap ?? () {}, + child: Text('Week $week'), + ), + ), + ), + ), + ), + ), + ), + ); + +FractionalTranslation _translation(WidgetTester tester, String layer) => + tester.widget(find.byKey(Key('week-transition-$layer'))); + +Finder get _layers => find.descendant( + of: find.byType(DirectionalWeekTransition), + matching: find.byType(FractionalTranslation), +); + +void main() { + testWidgets( + 'next and previous weeks move in opposite directions then settle', + (tester) async { + await tester.pumpWidget(_week(1)); + await tester.pumpWidget(_week(2)); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('Week 1'), findsOneWidget); + expect(find.text('Week 2'), findsOneWidget); + expect(_translation(tester, 'incoming').translation.dx, greaterThan(0)); + expect(_translation(tester, 'outgoing').translation.dx, lessThan(0)); + final fade = tester.widget( + find.descendant( + of: find.byKey(const Key('week-transition-incoming')), + matching: find.byType(Opacity), + ), + ); + expect(fade.opacity, greaterThan(0)); + expect(fade.opacity, lessThan(1)); + await tester.pumpAndSettle(); + expect(find.text('Week 1'), findsNothing); + expect(_translation(tester, 'incoming').translation, Offset.zero); + + await tester.pumpWidget(_week(1)); + await tester.pump(const Duration(milliseconds: 100)); + expect(_translation(tester, 'incoming').translation.dx, lessThan(0)); + expect(_translation(tester, 'outgoing').translation.dx, greaterThan(0)); + await tester.pumpAndSettle(); + expect(find.text('Week 2'), findsNothing); + expect(_translation(tester, 'incoming').translation, Offset.zero); + }, + ); + + testWidgets('outgoing courses cannot receive taps or duplicate semantics', ( + tester, + ) async { + final semantics = tester.ensureSemantics(); + try { + var oldTaps = 0; + await tester.pumpWidget(_week(1, onTap: () => oldTaps++)); + await tester.pumpWidget(_week(2)); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.bySemanticsLabel('week-1-course'), findsNothing); + expect(find.bySemanticsLabel('week-2-course'), findsOneWidget); + final oldPointer = tester.widget( + find + .ancestor( + of: find.text('Week 1'), + matching: find.byType(IgnorePointer), + ) + .first, + ); + expect(oldPointer.ignoring, isTrue); + await tester.tap(find.text('Week 1'), warnIfMissed: false); + expect(oldTaps, 0); + await tester.pumpAndSettle(); + } finally { + semantics.dispose(); + } + }); + + testWidgets( + 'rapid requests keep the running tween and coalesce latest week', + (tester) async { + await tester.pumpWidget(_week(1)); + await tester.pumpWidget(_week(2)); + await tester.pump(const Duration(milliseconds: 60)); + final before = _translation(tester, 'incoming').translation; + await tester.pumpWidget(_week(3)); + expect(_translation(tester, 'incoming').translation, before); + await tester.pumpWidget(_week(4)); + expect(_translation(tester, 'incoming').translation, before); + expect(_layers, findsNWidgets(2)); + expect(find.text('Week 3'), findsNothing); + await tester.pumpAndSettle(); + expect(find.text('Week 4'), findsOneWidget); + expect(find.text('Week 1'), findsNothing); + expect(find.text('Week 2'), findsNothing); + expect(_layers, findsOneWidget); + expect(_translation(tester, 'incoming').translation, Offset.zero); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('rapid reversal returns to latest week without stale layers', ( + tester, + ) async { + await tester.pumpWidget(_week(1)); + await tester.pumpWidget(_week(2)); + await tester.pump(const Duration(milliseconds: 70)); + await tester.pumpWidget(_week(3)); + await tester.pumpWidget(_week(1)); + await tester.pump(const Duration(milliseconds: 210)); + await tester.pump(const Duration(milliseconds: 100)); + expect(_translation(tester, 'incoming').translation.dx, lessThan(0)); + await tester.pumpAndSettle(); + expect(find.text('Week 1'), findsOneWidget); + expect(find.text('Week 2'), findsNothing); + expect(find.text('Week 3'), findsNothing); + expect(_layers, findsOneWidget); + }); + + testWidgets('reduced motion snaps and clears an in-flight transition', ( + tester, + ) async { + await tester.pumpWidget(_week(1)); + await tester.pumpWidget(_week(2)); + await tester.pump(const Duration(milliseconds: 70)); + await tester.pumpWidget(_week(3, reduceMotion: true)); + expect(find.text('Week 1'), findsNothing); + expect(find.text('Week 2'), findsNothing); + expect(find.text('Week 3'), findsOneWidget); + expect(_translation(tester, 'incoming').translation, Offset.zero); + expect(find.byKey(const Key('week-transition-outgoing')), findsNothing); + await tester.pumpWidget(_week(1, reduceMotion: true)); + expect(find.text('Week 1'), findsOneWidget); + expect(find.text('Week 3'), findsNothing); + expect(tester.hasRunningAnimations, isFalse); + }); +} diff --git a/server/feedback/.dockerignore b/server/feedback/.dockerignore new file mode 100644 index 0000000..d958af4 --- /dev/null +++ b/server/feedback/.dockerignore @@ -0,0 +1,11 @@ +.env +.env.* +!.env.example +.venv +.deps +.test* +.pytest_cache +__pycache__ +data +tests +*.sqlite3* diff --git a/server/feedback/.env.example b/server/feedback/.env.example new file mode 100644 index 0000000..10b39ad --- /dev/null +++ b/server/feedback/.env.example @@ -0,0 +1,17 @@ +# Server only. Never put these secrets in Flutter, the APK, or source control. +SMTP_HOST=smtp.feishu.cn +SMTP_PORT=465 +SMTP_TLS=ssl +SMTP_USERNAME=authors@glowtps.cn +SMTP_PASSWORD='REPLACE_WITH_FEISHU_MAIL_CLIENT_AUTHORIZATION_PASSWORD' +MAIL_FROM=authors@glowtps.cn +MAIL_TO=tps-david@glowtps.cn +# Generate once on the server, keep unchanged across container updates/restarts. +IDEMPOTENCY_SECRET='REPLACE_WITH_RANDOM_SECRET_AT_LEAST_32_BYTES' +DATABASE_PATH=/app/data/feedback.sqlite3 +SMTP_TIMEOUT_SECONDS=15 +RETENTION_DAYS=7 +IP_REQUESTS_PER_HOUR=10 +GLOBAL_REQUESTS_PER_HOUR=100 +MAX_CONCURRENT_REQUESTS=4 +UPLOAD_TIMEOUT_SECONDS=60 diff --git a/server/feedback/.gitignore b/server/feedback/.gitignore new file mode 100644 index 0000000..7ee9ebe --- /dev/null +++ b/server/feedback/.gitignore @@ -0,0 +1,10 @@ +.env +.env.* +!.env.example +.venv/ +.deps/ +.test*/ +.pytest_cache/ +__pycache__/ +data/ +*.sqlite3* diff --git a/server/feedback/Dockerfile b/server/feedback/Dockerfile new file mode 100644 index 0000000..fdef71e --- /dev/null +++ b/server/feedback/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +WORKDIR /app +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt \ + && useradd --system --uid 10001 --create-home feedback \ + && mkdir /app/data && chown feedback:feedback /app/data +COPY feedback_service.py main.py ./ +USER feedback +EXPOSE 8000 +# The host uses localhost port publishing; only that trusted proxy may supply client IP headers. +CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--proxy-headers", "--forwarded-allow-ips", "*", "--no-access-log", "--limit-concurrency", "32", "--timeout-keep-alive", "5", "--timeout-graceful-shutdown", "180"] diff --git a/server/feedback/README.md b/server/feedback/README.md new file mode 100644 index 0000000..8269b8e --- /dev/null +++ b/server/feedback/README.md @@ -0,0 +1,96 @@ +# App feedback → Feishu SMTP + +Prepared for Ubuntu + 1Panel on `43.138.57.216`. Public endpoint: `https://app.glowtps.cn/v1/feedback`. Server sender: `authors@glowtps.cn`; fixed receiver: `tps-david@glowtps.cn`. This directory contains deployment source; no connection to the server or live SMTP test has been performed. + +## Contract + +`POST /v1/feedback` uses `multipart/form-data` only. Each text field occurs once; repeated attachments use the field name `attachments`. + +| Field | Problem feedback | Development request | +|---|---|---| +| `submission_id` | Required random identifier, 16–128 characters `[A-Za-z0-9_-]` (UUID v4 works). Keep unchanged for retries. | Same | +| `type` | `problem` | `development` | +| `message` | Required, nonblank, max 5000 Unicode characters | Must be omitted, even if empty | +| `contact` | Optional, max 200 characters | Required, nonblank, max 200 characters | +| `school` | Must be omitted | Required, nonblank, max 200 characters | +| `attachments` | Optional, max 3 files, 8 MiB each, 20 MiB aggregate | Must be omitted | + +Accepted MIME types: `image/jpeg`, `image/png`, `image/gif`, `image/webp`, `image/heic`, `image/heif`, `video/mp4`, `video/quicktime`, `video/x-m4v`, `video/webm`. Send an explicit matching MIME type; `application/octet-stream` is rejected. Container signatures must match. MOV/MP4/M4V/HEIC require an initial valid `ftyp` box with a recognized compatible brand; legacy MOV without `ftyp` is rejected. Checks identify file formats; they do not certify an attachment is harmless or a complete playable file. + +Filenames lose directory components, control characters and unsupported characters, and are bounded. Nothing is served as public media. All text goes into a plain-text mail body; sender/recipient and mail headers never come from submitted text. + +Success is **HTTP 200** with exactly: + +```json +{"success":true,"submission_id":"client-generated-id","status":"sent"} +``` + +This means the configured SMTP server acknowledged message acceptance with `250`, and that status was committed locally. It does not guarantee inbox delivery or reading. Same-ID/same-content retries return the recorded success without sending again. Fingerprints include exact field values, sanitized filenames, MIME types and attachment content in the original order. Retry with the same payload and ID; generate a new ID when editing a previously submitted payload. + +All failures use non-2xx responses, with `success:false`, `error`, `detail` and (where applicable) `submission_id`/`status`: + +| HTTP | Error/status | Action | +|---|---|---| +| 400 / 408 | Malformed body / upload timeout | Fix request or retry the same ID | +| 413 / 415 / 422 | Upload limits / unsupported media / invalid fields | Fix payload; no mail was attempted | +| 409 | `id_conflict` | This ID already belongs to other content; do not blindly resend | +| 409 | `in_progress` | Wait, then retry the identical ID and payload | +| 429 | `rate_limited` | Respect `Retry-After` | +| 503 | `server_busy` | Respect `Retry-After`; retain the ID | +| 503 | `smtp_unavailable`, status `retryable` | SMTP definitely did not accept mail; retry the same ID | +| 503 | `delivery_uncertain` or `storage_unavailable`, status `uncertain` | Stop automatic resubmission and ask the operator to check the mail by submission ID | + +Proxy-generated errors can be HTML rather than this JSON; clients must still treat them as failures. No `202` or queue acceptance counts as success. + +## Ubuntu / 1Panel deployment + +1. Point the DNS A record for `app.glowtps.cn` to `43.138.57.216`. In Tencent Cloud's security group allow web ports 80/443; keep 18080 closed to the public. Use the existing 1Panel/OpenResty installation. +2. Upload **this directory's deployable files**, without `.deps`, test caches, local database files or any developer environment file, to a dedicated server directory such as `/opt/app-feedback`. In 1Panel's file manager or server terminal, copy `.env.example` to `.env`; set its permissions to `600`. +3. In Feishu Mail, enable third-party mail-client access for `authors@glowtps.cn` and generate the client authorization password. Put it only in `SMTP_PASSWORD` on the server. Use `SMTP_USERNAME=authors@glowtps.cn`, `MAIL_FROM=authors@glowtps.cn` and `MAIL_TO=tps-david@glowtps.cn`. Single-quote secret values in `.env` so `$` is literal in Compose. Do not paste passwords into chat, Flutter code, Dockerfiles, terminal command history or screenshots. +4. Generate `IDEMPOTENCY_SECRET` once on the server (for example `openssl rand -hex 32`), insert it into `.env` and keep it stable across updates. Changing this key while retaining the database makes existing fingerprints conflict. Replace both placeholder secrets before starting. +5. From the uploaded directory run `docker compose up -d --build` (or create a 1Panel Compose project from `docker-compose.yml` with this directory as its build context). The image runs as an unprivileged user, the filesystem is read-only except the named SQLite volume and temporary upload space, and the host publishes only `127.0.0.1:18080`. +6. Verify liveness locally with `curl --fail http://127.0.0.1:18080/healthz` and check `docker compose ps`. Health checks do **not** send mail or validate SMTP credentials. If startup fails, inspect container logs; the app validates required configuration without printing values. Do not share `docker compose config` or `docker inspect` output, because they can expose credentials. +7. In 1Panel, create/configure the `app.glowtps.cn` reverse-proxy website, obtain/select its HTTPS certificate and enable HTTPS redirection. Configure the upstream as `http://127.0.0.1:18080`. Add the location settings from `reverse-proxy.conf.example` inside the site's generated HTTPS server block, preserving its certificate paths. If the site already exists, add only the feedback and health locations rather than replacing other routes. In the generated config, avoid duplicate `location = /v1/feedback` blocks. Test OpenResty's configuration through 1Panel before reload. +8. Check that 1Panel's OpenResty has **host network access**. A proxy container on a bridge network has its own `127.0.0.1`; it cannot reach a localhost-only host publish. With that setup, configure a shared private Docker network and proxy to `feedback:8000`, remove the host port publish, and restrict proxy trust to that network instead of exposing 18080 publicly. The supplied example targets the host-access configuration; inspect the existing network mode before use. +9. Verify `https://app.glowtps.cn/healthz`. When explicitly ready to send a real message, submit one controlled feedback, confirm the response and receipt at `tps-david@glowtps.cn`, then retry the **same ID and payload** to verify only one mail arrives. That real-mail check remains to be done by the operator. + +Feishu's official documentation specifies `smtp.feishu.cn` with SSL on 465, and its business-system guide documents STARTTLS on 587. The service supports only verified TLS: `SMTP_PORT=465` + `SMTP_TLS=ssl` (default), or `SMTP_PORT=587` + `SMTP_TLS=starttls`. It never falls back to plaintext or disables certificate checks. Outbound 465/587 access must be permitted by the server/network. + +## Durable retries and retention + +SQLite in the named `feedback-data` volume stores only submission ID, a keyed content fingerprint, state and timestamps. It stores no form text, contact address, school, filename or raw attachment. Uploaded data is spooled to bounded temporary files and deleted on request completion; in Docker, the temporary area is a tmpfs. The resulting email and attachments are retained by Feishu/recipient mailbox policies, independently of this database. + +There is no atomic transaction across SMTP and SQLite. A lost `DATA` acknowledgement or crash after acceptance may mean mail arrived even though the HTTP request failed. Such records are quarantined as `uncertain`, preventing automatic resend. A `sending` record stays `in_progress` for five minutes; afterwards it becomes `uncertain`. The stable email Message-ID includes the submission ID, but recipient deduplication is not assumed. + +Metadata expires after seven days since the last state change (`RETENTION_DAYS`). Cleanup runs at startup, on submissions, and hourly while the process runs. The retry deduplication guarantee is bounded by this retention window; reusing an ID after expiry can send again. Client automatic retries must stop within the retention window. Keep the volume and key across deployments, and apply an appropriate retention policy to any backups. Use `docker compose down` for a stop; **do not use `down -v`** when preserving retry records. Back up SQLite consistently while the service is stopped or using SQLite's backup API, including its WAL requirements. + +To resolve an uncertain record, first check the recipient mailbox and provider delivery records for its submission ID. During a maintenance stop, use a trusted SQLite tool to inspect `submissions`. Change only that record's `status` to `sent` after verified acceptance, or to `retryable` only after verified non-acceptance, updating `updated` to the current Unix timestamp. Never automatically clear uncertain records, delete the volume or issue a new submission ID to force a retry. + +## Limits and operations + +The service counts raw HTTP bytes, including chunked input, with a 20 MiB + 64 KiB request ceiling; the proxy allows 21 MiB to leave the final precise check to the service. Text parts, field count and file count are bounded. Four active feedback requests are allowed by default, with a 60-second upload deadline. Default limits are ten requests/IP/hour and 100 requests globally/hour. These include invalid requests and retries. The in-memory rate counters reset after restart; SQLite deduplication survives. Proxy IP headers are overwritten; the private upstream must stay private. + +Run exactly **one worker and one service replica** with this configuration: rate/concurrency counters are per-process. SQLite claims serialize concurrent IDs, but scaling needs a shared rate limiter and an appropriate storage design. The default 512 MiB memory and 256 MiB tmpfs budget account for multipart and MIME encoding of several uploads; if reducing these, also reduce concurrency. Large attachments can grow by roughly a third in email MIME encoding and may be rejected by provider message-size limits; a definite rejection returns a failure. + +The app logs neither user fields nor SMTP responses/secrets; Uvicorn access logs are disabled. Feedback-path proxy access logs are also disabled in the example. Do not enable SMTP debug logging, request-body/WAF capture or form-content tracing on this endpoint. Allowlisted media are attached to plain-text mail only and never executed by the service. For higher abuse exposure, add server-side account verification/CAPTCHA through the app's existing identity system; no static secret embedded in an APK provides meaningful access control. + +## Local verification (no real SMTP) + +Requires Python 3.11+; the supplied image uses 3.12. Install dependencies into a virtual environment in this directory: + +```sh +python3 -m venv .venv +. .venv/bin/activate +python -m pip install -r requirements-dev.txt +python -m pytest -q +``` + +Tests inject a recording sender or scripted SMTP protocol object. They cover type-specific validation, file MIME/signatures, size/count and chunked-body limits, definite/uncertain SMTP outcomes, verified SSL/STARTTLS order, recipient fixation, persistence/restarts, conflicting IDs, concurrent retries, storage-commit failure, rate/concurrency limits, and metadata privacy. No SMTP connection, credentials or live mail is needed. + +## References + +- [Feishu: third-party mail client login](https://www.feishu.cn/hc/en-US/articles/902478147400-log-in-to-feishu-mail-through-a-third-party-email-client) +- [Feishu: SMTP migration / 465 SSL and 587 STARTTLS](https://www.feishu.cn/hc/en-US/articles/221905160046-update-the-smtp-server-settings-in-business-systems-during-the-email-migration-process) +- [1Panel: create websites and reverse proxies](https://docs.fit2cloud.com/1panel/user_manual/websites/website-create/) +- [Starlette: bounded multipart form parsing](https://starlette.dev/requests/) +- [Docker Compose: quoted environment values](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/) diff --git a/server/feedback/docker-compose.yml b/server/feedback/docker-compose.yml new file mode 100644 index 0000000..e73464f --- /dev/null +++ b/server/feedback/docker-compose.yml @@ -0,0 +1,34 @@ +name: app-feedback +services: + feedback: + build: . + restart: unless-stopped + env_file: + - .env + ports: + - "127.0.0.1:18080:8000" + volumes: + - feedback-data:/app/data + read_only: true + tmpfs: + - /tmp:size=256m,mode=1777 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + mem_limit: 512m + cpus: 1.0 + pids_limit: 64 + stop_grace_period: 180s + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz', timeout=3).read()"] + interval: 30s + timeout: 5s + retries: 3 + logging: + driver: json-file + options: + max-size: "5m" + max-file: "2" +volumes: + feedback-data: diff --git a/server/feedback/feedback_service.py b/server/feedback/feedback_service.py new file mode 100644 index 0000000..8e4cc35 --- /dev/null +++ b/server/feedback/feedback_service.py @@ -0,0 +1,513 @@ +"""Feedback submission service. Never log user text, uploads, or SMTP secrets.""" + +import asyncio +import hashlib +import hmac +import json +import os +import re +import smtplib +import sqlite3 +import ssl +import tempfile +import threading +import time +from collections import deque +from contextlib import asynccontextmanager, contextmanager, suppress +from dataclasses import dataclass, field +from email.message import EmailMessage +from email.policy import SMTP +from email.utils import formatdate +from pathlib import Path + +from fastapi import FastAPI, Request +from starlette.datastructures import UploadFile +from starlette.exceptions import HTTPException +from starlette.responses import JSONResponse +from starlette.concurrency import run_in_threadpool + +MIB = 1024 * 1024 +FILE_LIMIT = 8 * MIB +TOTAL_FILE_LIMIT = 20 * MIB +BODY_LIMIT = TOTAL_FILE_LIMIT + 64 * 1024 +ID_PATTERN = re.compile(r"[A-Za-z0-9_-]{16,128}\Z") +ADDRESS_PATTERN = re.compile(r"[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,63}\Z") + + +@dataclass +class Settings: + database_path: str = "data/feedback.sqlite3" + smtp_host: str = "smtp.feishu.cn" + smtp_port: int = 465 + smtp_tls: str = "ssl" + smtp_username: str = "" + smtp_password: str = field(default="", repr=False) + mail_from: str = "" + mail_to: str = "" + idempotency_secret: str = field(default="", repr=False) + smtp_timeout_seconds: int = 15 + retention_days: int = 7 + inflight_stale_seconds: int = 300 + ip_requests_per_hour: int = 10 + global_requests_per_hour: int = 100 + max_concurrent_requests: int = 4 + upload_timeout_seconds: int = 60 + + @classmethod + def from_env(cls): + names = { + "database_path": "DATABASE_PATH", "smtp_host": "SMTP_HOST", "smtp_port": "SMTP_PORT", + "smtp_tls": "SMTP_TLS", "smtp_username": "SMTP_USERNAME", "smtp_password": "SMTP_PASSWORD", + "mail_from": "MAIL_FROM", "mail_to": "MAIL_TO", "idempotency_secret": "IDEMPOTENCY_SECRET", + "smtp_timeout_seconds": "SMTP_TIMEOUT_SECONDS", "retention_days": "RETENTION_DAYS", + "ip_requests_per_hour": "IP_REQUESTS_PER_HOUR", "global_requests_per_hour": "GLOBAL_REQUESTS_PER_HOUR", + "max_concurrent_requests": "MAX_CONCURRENT_REQUESTS", "upload_timeout_seconds": "UPLOAD_TIMEOUT_SECONDS", + } + defaults, values = cls(), {} + for name, env_name in names.items(): + if env_name in os.environ: + value = os.environ[env_name] + if isinstance(getattr(defaults, name), int): + try: + value = int(value) + except ValueError: + raise ValueError(f"{env_name} must be an integer") from None + values[name] = value + return cls(**values) + + def validate(self): + for name in ("mail_from", "mail_to"): + if not ADDRESS_PATTERN.fullmatch(getattr(self, name)): + raise ValueError(f"{name} must be one plain email address") + if not self.smtp_username.strip() or "\r" in self.smtp_username or "\n" in self.smtp_username: + raise ValueError("SMTP_USERNAME is required and must not contain newlines") + if not self.smtp_password or self.smtp_password.startswith("REPLACE_"): + raise ValueError("SMTP_PASSWORD is required") + if not re.fullmatch(r"[A-Za-z0-9.-]{1,253}", self.smtp_host): + raise ValueError("SMTP_HOST must be a hostname") + if (self.smtp_port, self.smtp_tls) not in {(465, "ssl"), (587, "starttls")}: + raise ValueError("Use SMTP_PORT=465/SMTP_TLS=ssl or SMTP_PORT=587/SMTP_TLS=starttls") + if len(self.idempotency_secret.encode()) < 32 or self.idempotency_secret.startswith("REPLACE_"): + raise ValueError("IDEMPOTENCY_SECRET must have at least 32 bytes") + for name in ("retention_days", "smtp_timeout_seconds", "inflight_stale_seconds", + "ip_requests_per_hour", "global_requests_per_hour", "max_concurrent_requests", + "upload_timeout_seconds"): + if getattr(self, name) <= 0: + raise ValueError(f"{name} must be positive") + if self.smtp_timeout_seconds > 30 or self.max_concurrent_requests > 16: + raise ValueError("SMTP timeout must be <=30 seconds and concurrency <=16") + + +class ApiError(Exception): + def __init__(self, http_status, error, detail, *, submission_id=None, status=None): + self.http_status, self.error, self.detail = http_status, error, detail + self.submission_id, self.status = submission_id, status + + +class SendFailure(Exception): + """SMTP definitely did not accept this message. Retrying is safe.""" + + +class UncertainFailure(Exception): + """DATA may have been accepted, but its acknowledgement was lost.""" + + +def error_response(code, error, detail, *, submission_id=None, status=None, headers=None): + data = {"success": False, "error": error, "detail": detail} + if submission_id is not None: + data["submission_id"] = submission_id + if status is not None: + data["status"] = status + return JSONResponse(data, status_code=code, headers=headers) + + +class SmtpSender: + def __init__(self, settings): + self.settings = settings + + def __call__(self, message): + config, client, in_data, accepted = self.settings, None, False, False + raw_message = message.as_bytes(policy=SMTP) + try: + context = ssl.create_default_context() + if config.smtp_tls == "ssl": + client = smtplib.SMTP_SSL(config.smtp_host, config.smtp_port, + timeout=config.smtp_timeout_seconds, context=context) + else: + client = smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=config.smtp_timeout_seconds) + client.ehlo() + client.starttls(context=context) + client.ehlo() + client.login(config.smtp_username, config.smtp_password) + code, _ = client.mail(config.mail_from) + if code != 250: + raise SendFailure() + code, _ = client.rcpt(config.mail_to) + if code not in (250, 251): + raise SendFailure() + # A lost response from DATA is ambiguous: never automatically resend. + in_data = True + code, _ = client.data(raw_message) + if code != 250: + raise SendFailure() + accepted = True + except SendFailure: + raise + except smtplib.SMTPDataError: + raise SendFailure() from None + except (OSError, smtplib.SMTPException): + if in_data: + raise UncertainFailure() from None + raise SendFailure() from None + finally: + if client is not None: + try: + if accepted: + client.quit() + else: + client.close() + except (OSError, smtplib.SMTPException): + # QUIT failures do not undo an acknowledged DATA acceptance. + client.close() + + +class SubmissionStore: + def __init__(self, settings): + self.settings = settings + Path(settings.database_path).parent.mkdir(parents=True, exist_ok=True) + with self.connect() as db: + db.execute("PRAGMA journal_mode=WAL") + db.execute("CREATE TABLE IF NOT EXISTS submissions (" + "submission_id TEXT PRIMARY KEY, digest TEXT NOT NULL, status TEXT NOT NULL, " + "created REAL NOT NULL, updated REAL NOT NULL)") + db.execute("CREATE INDEX IF NOT EXISTS submissions_updated ON submissions(updated)") + self.prune() + + @contextmanager + def connect(self): + db = sqlite3.connect(self.settings.database_path, timeout=5) + try: + db.execute("PRAGMA synchronous=FULL") + db.execute("PRAGMA secure_delete=ON") + with db: + yield db + finally: + db.close() + + def claim(self, submission_id, digest): + now = time.time() + with self.connect() as db: + db.execute("BEGIN IMMEDIATE") + db.execute("DELETE FROM submissions WHERE updated < ?", + (now - self.settings.retention_days * 86400,)) + row = db.execute("SELECT digest, status, updated FROM submissions WHERE submission_id=?", + (submission_id,)).fetchone() + if row: + old_digest, status, updated = row + if not hmac.compare_digest(old_digest, digest): + return "conflict" + if status == "sending": + if now - updated <= self.settings.inflight_stale_seconds: + return "in_progress" + db.execute("UPDATE submissions SET status='uncertain', updated=? WHERE submission_id=?", + (now, submission_id)) + return "uncertain" + if status in ("sent", "uncertain"): + return status + db.execute("UPDATE submissions SET status='sending', updated=? WHERE submission_id=?", + (now, submission_id)) + else: + db.execute("INSERT INTO submissions VALUES (?, ?, 'sending', ?, ?)", + (submission_id, digest, now, now)) + return "claimed" + + def finish(self, submission_id, status): + with self.connect() as db: + db.execute("UPDATE submissions SET status=?, updated=? WHERE submission_id=?", + (status, time.time(), submission_id)) + + def prune(self): + with self.connect() as db: + db.execute("DELETE FROM submissions WHERE updated < ?", + (time.time() - self.settings.retention_days * 86400,)) + + +class AdmissionControl: + """Bounded per-process admission state: run exactly one worker.""" + def __init__(self, settings): + self.settings, self.lock, self.current = settings, threading.Lock(), 0 + self.global_times, self.ip_times = deque(), {} + + def enter(self, ip): + now = time.monotonic() + ip_key = hmac.new(self.settings.idempotency_secret.encode(), ip.encode(), hashlib.sha256).digest() + with self.lock: + while self.global_times and self.global_times[0] <= now - 3600: + self.global_times.popleft() + self.ip_times = {k: times for k, times in self.ip_times.items() if times[-1] > now - 3600} + times = self.ip_times.get(ip_key, deque()) + while times and times[0] <= now - 3600: + times.popleft() + if (len(times) >= self.settings.ip_requests_per_hour or + len(self.global_times) >= self.settings.global_requests_per_hour): + return error_response(429, "rate_limited", "Submission limit reached; try again later.", + headers={"Retry-After": "3600"}) + if self.current >= self.settings.max_concurrent_requests: + return error_response(503, "server_busy", "Server is busy; retry with the same submission ID.", + headers={"Retry-After": "15"}) + self.ip_times[ip_key] = times + times.append(now) + self.global_times.append(now) + self.current += 1 + return None + + def leave(self): + with self.lock: + self.current -= 1 + + +class BoundedFeedbackBody: + """Count raw bytes before parsing, including chunked bodies and multipart overhead.""" + def __init__(self, app, settings, admission): + self.app, self.settings, self.admission = app, settings, admission + + async def __call__(self, scope, receive, send): + if scope["type"] != "http" or scope.get("path") != "/v1/feedback" or scope["method"] != "POST": + return await self.app(scope, receive, send) + denial = self.admission.enter((scope.get("client") or ("unknown",))[0]) + if denial: + return await denial(scope, receive, send) + try: + headers = dict(scope.get("headers", [])) + if headers.get(b"content-type", b"").split(b";", 1)[0].strip().lower() != b"multipart/form-data": + return await error_response(415, "unsupported_content_type", "Use multipart/form-data.")(scope, receive, send) + if b"content-length" in headers: + try: + length = int(headers[b"content-length"]) + except ValueError: + return await error_response(400, "invalid_body", "Invalid Content-Length.")(scope, receive, send) + if length < 0: + return await error_response(400, "invalid_body", "Invalid Content-Length.")(scope, receive, send) + if length > BODY_LIMIT: + return await error_response(413, "upload_too_large", "Request exceeds upload limits.")(scope, receive, send) + with tempfile.SpooledTemporaryFile(max_size=MIB, mode="w+b") as spool: + total, deadline = 0, time.monotonic() + self.settings.upload_timeout_seconds + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + return await error_response(408, "upload_timeout", "Upload timed out.")(scope, receive, send) + try: + event = await asyncio.wait_for(receive(), remaining) + except TimeoutError: + return await error_response(408, "upload_timeout", "Upload timed out.")(scope, receive, send) + if event["type"] == "http.disconnect": + return + body = event.get("body", b"") + total += len(body) + if total > BODY_LIMIT: + return await error_response(413, "upload_too_large", "Request exceeds upload limits.")(scope, receive, send) + spool.write(body) + if not event.get("more_body", False): + break + spool.seek(0) + replayed = False + + async def replay_receive(): + nonlocal replayed + if replayed: + return {"type": "http.disconnect"} + block = spool.read(64 * 1024) + replayed = spool.tell() >= total + return {"type": "http.request", "body": block, "more_body": not replayed} + + return await self.app(scope, replay_receive, send) + finally: + self.admission.leave() + + +def file_signature(data, mime): + if mime == "image/jpeg": + return data.startswith(b"\xff\xd8\xff"), ".jpg" + if mime == "image/png": + return data.startswith(b"\x89PNG\r\n\x1a\n"), ".png" + if mime == "image/gif": + return data.startswith((b"GIF87a", b"GIF89a")), ".gif" + if mime == "image/webp": + return len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP", ".webp" + if mime == "video/webm": + return data.startswith(b"\x1a\x45\xdf\xa3") and b"webm" in data[:4096], ".webm" + if mime in {"image/heic", "image/heif", "video/mp4", "video/quicktime", "video/x-m4v"}: + if len(data) < 16 or data[4:8] != b"ftyp": + return False, "" + box_size = int.from_bytes(data[:4], "big") + if box_size < 16 or box_size > len(data) or box_size > 4096 or box_size % 4: + return False, "" + brands = {data[8:12]} | {data[i:i + 4] for i in range(16, box_size, 4)} + if mime in {"image/heic", "image/heif"}: + return bool(brands & {b"heic", b"heix", b"hevc", b"hevx", b"heim", b"heis", b"hevm", b"hevs"}), ".heic" + if mime == "video/quicktime": + return b"qt " in brands, ".mov" + return bool(brands & {b"isom", b"iso2", b"avc1", b"mp41", b"mp42", b"M4V ", b"M4VH", b"M4VP", b"dash", b"MSNV"}), ".mp4" + return False, "" + + +def safe_filename(filename, extension): + filename = (filename or "attachment").replace("\\", "/").rsplit("/", 1)[-1] + filename = re.sub(r"[^A-Za-z0-9._-]", "_", filename).strip("._")[:100] or "attachment" + alternatives = {".jpg": {".jpg", ".jpeg"}, ".heic": {".heic", ".heif"}, ".mp4": {".mp4", ".m4v"}} + if Path(filename).suffix.lower() not in alternatives.get(extension, {extension}): + filename = Path(filename).stem + extension + return filename + + +async def parse_submission(request): + try: + async with request.form(max_files=3, max_fields=5, max_part_size=20000) as form: + fields, uploads = {}, [] + for name, value in form.multi_items(): + if isinstance(value, UploadFile): + if name != "attachments": + raise ApiError(422, "validation_error", "Unexpected file field.") + uploads.append(value) + else: + if name not in {"submission_id", "type", "message", "contact", "school"} or name in fields: + raise ApiError(422, "validation_error", "Unexpected or duplicated form field.") + fields[name] = value + submission_id = fields.get("submission_id", "") + if not ID_PATTERN.fullmatch(submission_id): + raise ApiError(422, "validation_error", "submission_id must be a random 16–128 character identifier.") + kind = fields.get("type") + if kind not in {"problem", "development"}: + raise ApiError(422, "validation_error", "type must be problem or development.") + for name, maximum in (("message", 5000), ("contact", 200), ("school", 200)): + if len(fields.get(name, "")) > maximum: + raise ApiError(422, "validation_error", f"{name} exceeds {maximum} characters.") + if any("\x00" in text for text in fields.values()): + raise ApiError(422, "validation_error", "Null characters are not allowed.") + if kind == "problem": + if not fields.get("message", "").strip() or "school" in fields: + raise ApiError(422, "validation_error", "Problem feedback requires message and must omit school.") + elif ("message" in fields or uploads or not fields.get("school", "").strip() + or not fields.get("contact", "").strip()): + raise ApiError(422, "validation_error", "Development requests require school and contact; omit message and attachments.") + attachments, total = [], 0 + for upload in uploads: + data = await upload.read(FILE_LIMIT + 1) + total += len(data) + if len(data) > FILE_LIMIT or total > TOTAL_FILE_LIMIT: + raise ApiError(413, "upload_too_large", "Maximum three files, 8 MiB each and 20 MiB total.") + mime = (upload.content_type or "").lower() + valid, extension = file_signature(data, mime) + if not valid: + raise ApiError(415, "unsupported_media", "Attachment MIME type and file signature must match a supported image or video.") + attachments.append((safe_filename(upload.filename, extension), mime, data)) + return fields, attachments + except HTTPException as exc: + detail = str(exc.detail).lower() + if "too many" in detail or "maximum" in detail or "exceeded maximum size" in detail: + raise ApiError(413, "upload_too_large", "Multipart field or upload limits exceeded.") from None + raise ApiError(400, "invalid_body", "Malformed multipart body.") from None + + +def make_message(settings, fields, attachments): + message = EmailMessage(policy=SMTP) + message["From"], message["To"] = settings.mail_from, settings.mail_to + message["Subject"] = f"[App feedback] {fields['type']} / {fields['submission_id']}" + message["Message-ID"] = f"<{fields['submission_id']}@{settings.mail_from.split('@')[1]}>" + message["Date"] = formatdate(localtime=False, usegmt=True) + lines = [f"Submission ID: {fields['submission_id']}", f"Type: {fields['type']}"] + if fields["type"] == "development": + lines += ["School:", fields["school"], "Contact:", fields["contact"]] + else: + lines += ["Contact:", fields.get("contact", ""), "Message:", fields["message"]] + message.set_content("\n".join(lines), charset="utf-8") + for name, mime, data in attachments: + main, sub = mime.split("/", 1) + message.add_attachment(data, maintype=main, subtype=sub, filename=name) + return message + + +def create_app(settings=None, sender=None): + settings = settings or Settings.from_env() + settings.validate() + store = SubmissionStore(settings) + + @asynccontextmanager + async def lifespan(app): + async def cleanup(): + while True: + await asyncio.sleep(3600) + # Persistent storage failure makes readiness fail instead of exposing data in logs. + try: + await run_in_threadpool(store.prune) + app.state.retention_healthy = True + except sqlite3.Error: + app.state.retention_healthy = False + + app.state.retention_healthy = True + task = asyncio.create_task(cleanup()) + try: + yield + finally: + task.cancel() + with suppress(asyncio.CancelledError): + await task + + app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None, lifespan=lifespan) + app.state.sender = sender or SmtpSender(settings) + app.add_middleware(BoundedFeedbackBody, settings=settings, admission=AdmissionControl(settings)) + + @app.exception_handler(ApiError) + async def api_error_handler(request, exc): + return error_response(exc.http_status, exc.error, exc.detail, + submission_id=exc.submission_id, status=exc.status) + + @app.get("/healthz") + async def health(): + # Liveness only: never sends email or claims SMTP readiness. + if not getattr(app.state, "retention_healthy", True): + return error_response(503, "storage_unavailable", "Retention cleanup requires operator attention.") + return {"status": "ok"} + + @app.post("/v1/feedback") + async def feedback(request: Request): + fields, attachments = await parse_submission(request) + submission_id = fields["submission_id"] + canonical = json.dumps({"fields": fields, "attachments": [ + {"name": name, "mime": mime, "sha256": hashlib.sha256(data).hexdigest()} + for name, mime, data in attachments]}, sort_keys=True, ensure_ascii=False, separators=(",", ":")) + digest = hmac.new(settings.idempotency_secret.encode(), canonical.encode(), hashlib.sha256).hexdigest() + message = make_message(settings, fields, attachments) + + def deliver(): + try: + outcome = store.claim(submission_id, digest) + if outcome == "conflict": + raise ApiError(409, "id_conflict", "This ID was already used with different content.", submission_id=submission_id) + if outcome == "in_progress": + raise ApiError(409, "in_progress", "A submission with this ID is still being processed.", + submission_id=submission_id, status="in_progress") + if outcome == "uncertain": + raise ApiError(503, "delivery_uncertain", "Mail acceptance is uncertain; do not submit a new ID. Contact the operator.", + submission_id=submission_id, status="uncertain") + if outcome == "sent": + return + try: + app.state.sender(message) + except SendFailure: + store.finish(submission_id, "retryable") + raise ApiError(503, "smtp_unavailable", "Mail was not accepted; retry with the same ID.", + submission_id=submission_id, status="retryable") from None + except Exception: + store.finish(submission_id, "uncertain") + raise ApiError(503, "delivery_uncertain", "Mail acceptance is uncertain; do not submit a new ID. Contact the operator.", + submission_id=submission_id, status="uncertain") from None + store.finish(submission_id, "sent") + except sqlite3.Error: + raise ApiError(503, "storage_unavailable", "Submission state could not be confirmed; retry only with the same ID.", + submission_id=submission_id, status="uncertain") from None + + await run_in_threadpool(deliver) + return {"success": True, "submission_id": submission_id, "status": "sent"} + + return app diff --git a/server/feedback/main.py b/server/feedback/main.py new file mode 100644 index 0000000..30e26dd --- /dev/null +++ b/server/feedback/main.py @@ -0,0 +1,3 @@ +from feedback_service import create_app + +app = create_app() diff --git a/server/feedback/requirements-dev.txt b/server/feedback/requirements-dev.txt new file mode 100644 index 0000000..3566c9e --- /dev/null +++ b/server/feedback/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==9.1.1 +httpx2==2.13.1 diff --git a/server/feedback/requirements.txt b/server/feedback/requirements.txt new file mode 100644 index 0000000..94ba370 --- /dev/null +++ b/server/feedback/requirements.txt @@ -0,0 +1,15 @@ +fastapi==0.143.0 +starlette==1.8.0 +uvicorn==0.54.0 +python-multipart==0.0.32 +anyio==4.15.1 +pydantic==2.14.1 +pydantic-core==2.50.1 +typing-extensions==4.16.0 +typing-inspection==0.4.4 +annotated-doc==0.0.5 +annotated-types==0.8.0 +opentelemetry-api==1.45.1 +click==8.5.0 +h11==0.16.0 +idna==3.20 diff --git a/server/feedback/reverse-proxy.conf.example b/server/feedback/reverse-proxy.conf.example new file mode 100644 index 0000000..bcbe278 --- /dev/null +++ b/server/feedback/reverse-proxy.conf.example @@ -0,0 +1,32 @@ +# Insert these locations inside the HTTPS server block for app.glowtps.cn. +# Keep 1Panel's generated certificate paths/listen/server_name directives. +# This assumes OpenResty can access the host's 127.0.0.1 (host network mode). +# Set the site to force HTTPS. Do not expose the upstream port publicly. + +location = /v1/feedback { + client_max_body_size 21m; + client_body_timeout 60s; + # Avoid persistent proxy copies of feedback media. + proxy_request_buffering off; + proxy_buffering off; + proxy_http_version 1.1; + proxy_pass http://127.0.0.1:18080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + # Overwrite any header sent by the client so IP rate limiting cannot be spoofed. + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Connection ""; + proxy_connect_timeout 5s; + proxy_send_timeout 180s; + proxy_read_timeout 180s; + # NEVER retry a feedback POST against another upstream automatically. + proxy_next_upstream off; + # Disable access logs for this path (no query strings, client IPs or form content). + access_log off; +} + +location = /healthz { + proxy_pass http://127.0.0.1:18080; + access_log off; +} diff --git a/server/feedback/tests/test_feedback.py b/server/feedback/tests/test_feedback.py new file mode 100644 index 0000000..18ebd23 --- /dev/null +++ b/server/feedback/tests/test_feedback.py @@ -0,0 +1,500 @@ +import asyncio +import sqlite3 +import threading +import tempfile +import time +import smtplib +from types import SimpleNamespace +import feedback_service + +import pytest +from fastapi.testclient import TestClient + +from feedback_service import Settings, SendFailure, UncertainFailure, SmtpSender, SubmissionStore, create_app + + +ID = "cbaaaace-60b2-4871-8a95-ae316444a2fb" +PNG = b"\x89PNG\r\n\x1a\n" + b"safe fixture" + + +class RecordingSender: + def __init__(self): + self.messages = [] + self.failure = None + + def __call__(self, message): + if self.failure: + raise self.failure + self.messages.append(message) + + +@pytest.fixture +def service(tmp_path, monkeypatch): + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + settings = Settings(database_path=str(tmp_path / "feedback.sqlite3"), + smtp_username="feedback@example.com", smtp_password="test-only", + mail_from="feedback@example.com", mail_to="owner@example.com", + idempotency_secret="test-key-at-least-thirty-two-characters") + sender = RecordingSender() + app = create_app(settings, sender) + with TestClient(app) as client: + yield client, sender, settings + + +def post(client, **overrides): + fields = {"submission_id": ID, "type": "problem", "message": "The timetable is missing."} + fields.update(overrides) + return client.post("/v1/feedback", files=[(k, (None, v)) for k, v in fields.items()]) + + +def test_success_only_after_sender_accepted_and_retry_is_durable(service): + client, sender, settings = service + response = post(client, contact="student@example.com") + assert response.status_code == 200 + assert response.json() == {"success": True, "submission_id": ID, "status": "sent"} + assert len(sender.messages) == 1 + assert sender.messages[0]["From"] == "feedback@example.com" + assert sender.messages[0]["To"] == "owner@example.com" + assert "student@example.com" in sender.messages[0].get_body().get_content() + # A new application instance reads the persisted status, not an in-memory cache. + with TestClient(create_app(settings, sender)) as restarted: + assert post(restarted, contact="student@example.com").json()["status"] == "sent" + assert len(sender.messages) == 1 + + +def test_reusing_id_with_other_content_rejected(service): + client, sender, _ = service + assert post(client).status_code == 200 + assert post(client, message="Changed complaint").status_code == 409 + assert len(sender.messages) == 1 + + +@pytest.mark.parametrize("fields", [ + {"message": ""}, {"message": "x" * 5001}, {"contact": "x" * 201}, + {"school": "unexpected"}, {"type": "other"}, {"submission_id": "too-short"}, + {"type": "development", "school": "Example school", "contact": ""}, + {"type": "development", "school": "", "contact": "12345678"}, + {"type": "development", "school": "Example school", "contact": "12345678"}, +]) +def test_invalid_fields_do_not_send(service, fields): + client, sender, _ = service + assert post(client, **fields).status_code == 422 + assert not sender.messages + + +def test_development_has_only_school_and_contact(service): + client, sender, _ = service + response = client.post("/v1/feedback", files=[ + ("submission_id", (None, ID)), ("type", (None, "development")), + ("school", (None, "Example school")), ("contact", (None, "student@example.com")), + ]) + assert response.status_code == 200 + body = sender.messages[0].get_body().get_content() + assert "Example school" in body and "student@example.com" in body + + +def test_attachment_is_sanitized_and_type_checked(service): + client, sender, _ = service + files = [("submission_id", (None, ID)), ("type", (None, "problem")), + ("message", (None, "Attachment")), + ("attachments", ("../../bad\r\nname.png", PNG, "image/png"))] + response = client.post("/v1/feedback", files=files) + assert response.status_code == 200 + attachment = list(sender.messages[0].iter_attachments())[0] + assert attachment.get_filename() == "bad_0D_0Aname.png" + assert attachment.get_payload(decode=True) == PNG + files[-1] = ("attachments", ("pretend.png", b"", "image/png")) + assert client.post("/v1/feedback", files=files).status_code == 415 + + +def test_development_rejects_media(service): + client, sender, _ = service + response = client.post("/v1/feedback", files=[ + ("submission_id", (None, ID)), ("type", (None, "development")), + ("school", (None, "Example")), ("contact", (None, "student@example.com")), + ("attachments", ("a.png", PNG, "image/png")), + ]) + assert response.status_code == 422 + assert not sender.messages + + +def test_duplicate_fields_and_unexpected_fields_rejected(service): + client, sender, _ = service + for extra in [("message", (None, "second")), ("recipient", (None, "victim@example.com"))]: + response = client.post("/v1/feedback", files=[ + ("submission_id", (None, ID)), ("type", (None, "problem")), + ("message", (None, "Complaint")), extra, + ]) + assert response.status_code == 422 + assert not sender.messages + + +def test_definite_smtp_failure_is_non_success_and_can_retry(service): + client, sender, _ = service + sender.failure = SendFailure() + response = post(client) + assert response.status_code == 503 + assert response.json()["success"] is False + assert response.json()["status"] == "retryable" + sender.failure = None + assert post(client).json()["status"] == "sent" + + +def test_uncertain_smtp_acceptance_never_auto_resends(service): + client, sender, settings = service + sender.failure = UncertainFailure() + response = post(client) + assert response.status_code == 503 + assert response.json()["status"] == "uncertain" + sender.failure = None + with TestClient(create_app(settings, sender)) as restarted: + response = post(restarted) + assert response.status_code == 503 + assert response.json()["error"] == "delivery_uncertain" + assert not sender.messages + + +def test_parallel_retry_does_not_send_twice(service): + client, sender, _ = service + started, finish = threading.Event(), threading.Event() + + def blocking_sender(message): + started.set() + assert finish.wait(10) + sender(message) + + client.app.state.sender = blocking_sender + result = [] + worker = threading.Thread(target=lambda: result.append(post(client))) + worker.start() + assert started.wait(10) + try: + response = post(client) + assert response.status_code == 409 + assert response.json()["status"] == "in_progress" + finally: + finish.set() + worker.join(10) + assert result[0].status_code == 200 + assert len(sender.messages) == 1 + + +def test_stale_inflight_record_is_uncertain(service): + client, sender, settings = service + assert post(client).status_code == 200 + with sqlite3.connect(settings.database_path) as db: + db.execute("UPDATE submissions SET status='sending', updated=?", (time.time() - 600,)) + response = post(client) + assert response.status_code == 503 + assert response.json()["status"] == "uncertain" + assert len(sender.messages) == 1 + + +def test_file_count_and_individual_size_are_limited(service): + client, sender, _ = service + base = [("submission_id", (None, ID)), ("type", (None, "problem")), + ("message", (None, "Complaint"))] + response = client.post("/v1/feedback", files=base + [ + ("attachments", (f"{i}.png", PNG, "image/png")) for i in range(4)]) + assert response.status_code == 413 + response = client.post("/v1/feedback", files=base + [ + ("attachments", ("large.png", PNG + b"x" * (8 * 1024 * 1024), "image/png"))]) + assert response.status_code == 413 + assert not sender.messages + + +def test_aggregate_attachments_are_limited(service): + client, sender, _ = service + base = [("submission_id", (None, ID)), ("type", (None, "problem")), + ("message", (None, "Complaint"))] + response = client.post("/v1/feedback", files=base + [ + ("attachments", (f"{i}.png", PNG + b"x" * (7 * 1024 * 1024), "image/png")) + for i in range(3)]) + assert response.status_code == 413 + assert not sender.messages + + +def test_chunked_body_without_content_length_is_limited(service): + client, sender, _ = service + events, output = iter([ + {"type": "http.request", "body": b"x" * (11 * 1024 * 1024), "more_body": True}, + {"type": "http.request", "body": b"x" * (11 * 1024 * 1024), "more_body": False}, + ]), [] + + async def receive(): + return next(events) + + async def send(event): + output.append(event) + + scope = {"type": "http", "asgi": {"version": "3.0"}, "method": "POST", + "path": "/v1/feedback", "raw_path": b"/v1/feedback", "query_string": b"", + "headers": [(b"content-type", b"multipart/form-data; boundary=abc")], + "client": ("127.0.0.1", 123), "server": ("localhost", 80), "scheme": "http"} + asyncio.run(client.app(scope, receive, send)) + assert output[0]["status"] == 413 + assert not sender.messages + + +def test_oversized_declared_body_is_rejected_before_parsing(service): + client, sender, _ = service + response = client.post("/v1/feedback", content=b"", headers={ + "content-type": "multipart/form-data; boundary=abc", "content-length": str(100 * 1024 * 1024)}) + assert response.status_code == 413 + assert not sender.messages + + +def test_request_rate_is_limited_before_smtp(service): + _, sender, settings = service + settings.ip_requests_per_hour = 2 + with TestClient(create_app(settings, sender)) as client: + assert post(client).status_code == 200 + assert post(client).status_code == 200 + response = post(client) + assert response.status_code == 429 + assert response.headers["retry-after"] + assert len(sender.messages) == 1 + + +def test_unsupported_body_format_cannot_use_unbounded_form_parser(service): + client, sender, _ = service + assert client.post("/v1/feedback", data={"type": "problem"}).status_code == 415 + assert not sender.messages + + +def test_persistent_metadata_does_not_contain_personal_text(service): + client, _, settings = service + assert post(client, message="private complaint", contact="private@example.com").status_code == 200 + with sqlite3.connect(settings.database_path) as db: + values = repr(db.execute("SELECT * FROM submissions").fetchall()) + assert "private complaint" not in values + assert "private@example.com" not in values + + +def test_invalid_environment_addresses_fail_before_startup(tmp_path): + for sender in ["feedback@example.com\r\nBcc: victim@example.com", "Display Name ", ""]: + settings = Settings(database_path=str(tmp_path / "db"), smtp_username="user", smtp_password="secret", + mail_from=sender, mail_to="owner@example.com") + with pytest.raises(ValueError): + create_app(settings, RecordingSender()) + + +def test_global_rate_cannot_be_bypassed_by_changing_client_ips(service): + _, sender, settings = service + settings.global_requests_per_hour = 2 + app = create_app(settings, sender) + for ip, expected in [("1.2.3.4", 200), ("2.3.4.5", 200), ("3.4.5.6", 429)]: + with TestClient(app, client=(ip, 123)) as client: + assert post(client).status_code == expected + assert len(sender.messages) == 1 + + +def test_concurrency_is_bounded_before_parsing(service): + _, sender, settings = service + settings.max_concurrent_requests = 1 + started, finish = threading.Event(), threading.Event() + + def blocking_sender(message): + started.set() + assert finish.wait(10) + sender(message) + + with TestClient(create_app(settings, blocking_sender)) as client: + result = [] + worker = threading.Thread(target=lambda: result.append(post(client))) + worker.start() + assert started.wait(10) + try: + response = post(client) + assert response.status_code == 503 + assert response.json()["error"] == "server_busy" + finally: + finish.set() + worker.join(10) + assert result[0].status_code == 200 + assert len(sender.messages) == 1 + + +def test_lost_database_commit_after_acceptance_never_claims_success(service, monkeypatch): + client, sender, _ = service + + def storage_failure(self, submission_id, status): + raise sqlite3.OperationalError("simulated storage failure") + + monkeypatch.setattr(SubmissionStore, "finish", storage_failure) + response = post(client) + assert response.status_code == 503 + assert response.json()["success"] is False + assert response.json()["error"] == "storage_unavailable" + assert post(client).status_code == 409 + assert len(sender.messages) == 1 + + +class ScriptedSmtp: + """Protocol boundary fake; no socket is opened and no real mail is sent.""" + def __init__(self, phase="accept"): + self.phase, self.events, self.context = phase, [], None + + def login(self, user, password): + self.events.append("login") + if self.phase == "auth_fail": + raise smtplib.SMTPAuthenticationError(535, b"authentication rejected") + + def ehlo(self): + self.events.append("ehlo") + + def starttls(self, context): + self.context = context + self.events.append("starttls") + + def mail(self, sender): + self.events.append(("mail", sender)) + return 250, b"ok" + + def rcpt(self, recipient): + self.events.append(("rcpt", recipient)) + return (550, b"rejected") if self.phase == "recipient_fail" else (250, b"ok") + + def data(self, payload): + self.events.append("data") + if self.phase == "lost_ack": + raise smtplib.SMTPServerDisconnected("connection lost") + if self.phase == "data_reject": + raise smtplib.SMTPDataError(552, b"too big") + return 250, b"accepted" + + def quit(self): + self.events.append("quit") + if self.phase == "quit_fail": + raise smtplib.SMTPServerDisconnected("quit lost") + + def close(self): + self.events.append("close") + + +@pytest.mark.parametrize("phase, expected", [ + ("accept", None), ("quit_fail", None), ("auth_fail", SendFailure), + ("recipient_fail", SendFailure), ("data_reject", SendFailure), ("lost_ack", UncertainFailure), +]) +def test_smtp_outcome_tracks_data_acknowledgement(service, monkeypatch, phase, expected): + client, sender, settings = service + assert post(client).status_code == 200 + fake = ScriptedSmtp(phase) + + def connect(host, port, timeout, context): + assert host == "smtp.feishu.cn" and port == 465 + assert context.check_hostname and context.verify_mode == 2 + return fake + + monkeypatch.setattr(smtplib, "SMTP_SSL", connect) + if expected: + with pytest.raises(expected): + SmtpSender(settings)(sender.messages[0]) + else: + SmtpSender(settings)(sender.messages[0]) + assert ("mail", "feedback@example.com") in fake.events or phase == "auth_fail" + assert ("rcpt", "owner@example.com") in fake.events or phase == "auth_fail" + + +def test_starttls_is_verified_before_authentication(service, monkeypatch): + client, sender, settings = service + assert post(client).status_code == 200 + settings.smtp_port, settings.smtp_tls = 587, "starttls" + fake = ScriptedSmtp() + monkeypatch.setattr(smtplib, "SMTP", lambda host, port, timeout: fake) + SmtpSender(settings)(sender.messages[0]) + assert fake.events[:4] == ["ehlo", "starttls", "ehlo", "login"] + assert fake.context.check_hostname and fake.context.verify_mode == 2 + + +@pytest.mark.parametrize("name, mime, data", [ + ("a.jpg", "image/jpeg", b"\xff\xd8\xff\xe0fixture"), + ("a.gif", "image/gif", b"GIF89afixture"), + ("a.webp", "image/webp", b"RIFF\x00\x00\x00\x00WEBPfixture"), + ("a.heic", "image/heic", b"\x00\x00\x00\x14ftypheic\x00\x00\x00\x00mif1"), + ("a.mp4", "video/mp4", b"\x00\x00\x00\x14ftypisom\x00\x00\x00\x00mp42"), + ("a.m4v", "video/x-m4v", b"\x00\x00\x00\x14ftypM4V \x00\x00\x00\x00mp42"), + ("a.mov", "video/quicktime", b"\x00\x00\x00\x14ftypqt \x00\x00\x00\x00qt "), + ("a.webm", "video/webm", b"\x1a\x45\xdf\xa3doctypewebm"), +]) +def test_supported_media_signatures_reach_mail_attachment(service, name, mime, data): + client, sender, _ = service + response = client.post("/v1/feedback", files=[ + ("submission_id", (None, ID)), ("type", (None, "problem")), ("message", (None, "Issue")), + ("attachments", (name, data, mime)), + ]) + assert response.status_code == 200 + part = list(sender.messages[0].iter_attachments())[0] + assert part.get_payload(decode=True) == data + assert part.get_content_type() == mime + + +def test_expired_metadata_is_removed_at_startup_without_a_new_submission(service): + client, sender, settings = service + assert post(client).status_code == 200 + with sqlite3.connect(settings.database_path) as db: + db.execute("UPDATE submissions SET updated=?", (time.time() - 8 * 86400,)) + create_app(settings, sender) + with sqlite3.connect(settings.database_path) as db: + assert db.execute("SELECT COUNT(*) FROM submissions").fetchone()[0] == 0 + + +def test_slow_chunked_upload_times_out_before_sending(service): + _, sender, settings = service + settings.upload_timeout_seconds = 0.01 + app = create_app(settings, sender) + output = [] + + async def receive(): + await asyncio.sleep(0.1) + return {"type": "http.request", "body": b"", "more_body": True} + + async def send(event): + output.append(event) + + scope = {"type": "http", "asgi": {"version": "3.0"}, "method": "POST", + "path": "/v1/feedback", "raw_path": b"/v1/feedback", "query_string": b"", + "headers": [(b"content-type", b"multipart/form-data; boundary=abc")], + "client": ("127.0.0.1", 123), "server": ("localhost", 80), "scheme": "http"} + asyncio.run(app(scope, receive, send)) + assert output[0]["status"] == 408 + assert not sender.messages + + +def test_attachment_filename_extension_matches_detected_type(service): + client, sender, _ = service + response = client.post("/v1/feedback", files=[ + ("submission_id", (None, ID)), ("type", (None, "problem")), ("message", (None, "Issue")), + ("attachments", ("misleading.mov", PNG, "image/png")), + ]) + assert response.status_code == 200 + assert list(sender.messages[0].iter_attachments())[0].get_filename() == "misleading.png" + + +def test_absolute_upload_deadline_applies_even_when_chunks_are_ready(service, monkeypatch): + _, sender, settings = service + settings.upload_timeout_seconds = 1 + app = create_app(settings, sender) + clock = iter([0, 0, 0, 2]) + # Replace only this module's clock so the asyncio loop keeps real monotonic time. + monkeypatch.setattr(feedback_service, "time", SimpleNamespace( + monotonic=lambda: next(clock, 2), time=time.time)) + events = iter([ + {"type": "http.request", "body": b"first chunk", "more_body": True}, + {"type": "http.request", "body": b"second chunk", "more_body": False}, + ]) + output = [] + + async def receive(): + return next(events) + + async def send(event): + output.append(event) + + scope = {"type": "http", "asgi": {"version": "3.0"}, "method": "POST", + "path": "/v1/feedback", "raw_path": b"/v1/feedback", "query_string": b"", + "headers": [(b"content-type", b"multipart/form-data; boundary=abc")], + "client": ("127.0.0.1", 123), "server": ("localhost", 80), "scheme": "http"} + asyncio.run(app(scope, receive, send)) + assert output[0]["status"] == 408 + assert not sender.messages diff --git a/server/feedback/部署说明.md b/server/feedback/部署说明.md new file mode 100644 index 0000000..ef1ea56 --- /dev/null +++ b/server/feedback/部署说明.md @@ -0,0 +1,78 @@ +# 反馈邮件服务:1Panel 部署说明 + +已准备好服务文件,尚未连接服务器、部署或发送真实邮件。 + +| 项目 | 配置 | +|---|---| +| 服务器 | Ubuntu / 1Panel,公网 IP `43.138.57.216` | +| 域名 | `app.glowtps.cn` | +| App 提交地址 | `https://app.glowtps.cn/v1/feedback` | +| 飞书发件邮箱 | `authors@glowtps.cn` | +| 固定收件邮箱 | `tps-david@glowtps.cn` | +| 发信服务器 | `smtp.feishu.cn`,端口 `465`,SSL | + +## 1. 上传服务文件 + +在 1Panel 文件管理中,准备 `/opt/app-feedback` 目录,把本目录的服务文件上传进去。需要保留 `feedback_service.py`、`main.py`、`requirements.txt`、`Dockerfile`、`docker-compose.yml`、`.dockerignore`、`.env.example` 和 `reverse-proxy.conf.example`。 + +不要上传开发电脑上的 `.deps`、`.venv`、`.test*`、缓存目录、数据库或已有的 `.env`。这些文件不属于部署包。 + +## 2. 在服务器填写 `.env` + +将 `.env.example` 复制为 `.env`,确认以下公开配置: + +```dotenv +SMTP_HOST=smtp.feishu.cn +SMTP_PORT=465 +SMTP_TLS=ssl +SMTP_USERNAME=authors@glowtps.cn +MAIL_FROM=authors@glowtps.cn +MAIL_TO=tps-david@glowtps.cn +DATABASE_PATH=/app/data/feedback.sqlite3 +``` + +另外填写两个服务器专用秘密: + +- `SMTP_PASSWORD`:在飞书邮箱启用第三方邮件客户端访问后取得的客户端授权密码。仅在服务器 `.env` 中填写,不要写进 App、APK 或源代码,也不要发到聊天中。 +- `IDEMPOTENCY_SECRET`:用于保护重复提交记录的随机密钥,至少 32 字节。可在服务器终端执行 `openssl rand -hex 32` 生成,再填入 `.env`。后续更新服务时保留这个值。 + +两个示例占位值必须替换。建议用英文单引号包住秘密值,避免其中的 `$` 被 Compose 替换。通过文件管理或 `chmod 600 .env` 限制 `.env` 的读取权限。其余限制参数可以保留默认值。 + +## 3. 启动 Compose + +在 1Panel 的服务器终端进入上传目录并启动: + +```sh +cd /opt/app-feedback +docker compose up -d --build +docker compose ps +curl --fail http://127.0.0.1:18080/healthz +``` + +也可以使用 1Panel 的容器编排入口导入 `docker-compose.yml`;需要确认构建目录是 `/opt/app-feedback`,同一目录内能找到 Dockerfile、服务文件和 `.env`。 + +`healthz` 正常时会返回 `{"status":"ok"}`。它不会发送邮件,也不代表飞书密码已经验证。如果启动失败,在 1Panel 查看容器日志。不要公开分享 `.env`、完整容器详情或 `docker compose config` 输出,这些内容可能包含密码。 + +## 4. 配置域名、HTTPS 和反向代理 + +1. 在域名 DNS 中,将 `app.glowtps.cn` 的 A 记录指向 `43.138.57.216`。 +2. 在腾讯云安全组中允许网站所需的 80、443 端口;`18080` 保持对公网关闭。 +3. 在 1Panel 配置 `app.glowtps.cn` 网站,申请或选择 HTTPS 证书,启用 HTTPS 跳转。 +4. 设置反向代理到 `http://127.0.0.1:18080`,并参考 `reverse-proxy.conf.example` 加入反馈接口和健康检查的配置。保留 1Panel 生成的证书设置;如果域名已有网站,只增加对应接口,保留其他页面和路由。 +5. 通过 1Panel 检查配置后再重新加载 OpenResty,访问 `https://app.glowtps.cn/healthz` 验证。 + +**网络方式需要确认:**上述地址要求 OpenResty 能访问宿主机的 `127.0.0.1`。如果 OpenResty 使用独立的 Docker 桥接网络,它的 `127.0.0.1` 是自己的容器;此时需让 OpenResty 和反馈服务加入同一个私有 Docker 网络,改为代理 `feedback:8000`,并取消宿主机端口发布、调整可信代理设置。不要通过向公网开放 `18080` 来解决连接问题。具体配置见同目录英文 README。 + +## 5. 由操作者进行一次真实邮件验证 + +服务器和证书准备完成后,由操作者从 App 提交一条明确标记为“部署测试”的反馈,确认接口返回 `success:true`、`status:sent`,并在 `tps-david@glowtps.cn` 检查收件。再使用**相同提交 ID 和相同内容**重试一次,确认没有重复邮件。 + +接口成功表示飞书发信服务器已接受邮件;是否进入收件箱仍需检查。若返回“发送状态不确定”,先检查收件和投递记录,不要换一个提交 ID 强行重发。完整错误说明见 README。 + +## 6. 更新时保留数据和秘密 + +服务使用 Compose 的 SQLite 数据卷保存重复提交状态,默认保留七天;文本、联系方式和附件不写入这个数据库。邮件本身按飞书及收件邮箱的策略保存。 + +更新时保留 `.env`、原来的 `IDEMPOTENCY_SECRET` 和 SQLite 数据卷,继续在同一部署目录使用同一个 Compose 项目。需要停服时使用 `docker compose down`,**不要使用 `docker compose down -v`**,后者会删除数据卷并丢失重复提交记录。需要备份时采用一致的 SQLite 备份方式,详细方法见 README。 + +目前还需要在服务器完成:填写飞书客户端授权密码、生成持久密钥、启动容器、检查 DNS/证书/代理网络,并执行一次受控的真实收件验证。