From 961523d94c019c74bd447dc2e6877cc13e89bfea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Mon, 24 Aug 2026 12:35:24 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=81=E7=A7=BB=20Flutter=20=E7=AB=AF?= =?UTF-8?q?=E4=B8=8E=E8=AE=AD=E7=BB=83=E8=84=9A=E6=9C=AC=EF=BC=8C=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B/=E8=AE=AD=E7=BB=83=E4=BA=A7=E7=89=A9=E7=A7=BB?= =?UTF-8?q?=E5=87=BA=20git=EF=BC=88=E9=81=B5=E5=BE=AA=E7=BA=AF=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E7=BA=A6=E5=AE=9A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 9 + app/build.gradle.kts | 80 - app/proguard-rules.pro | 3 - app/src/main/AndroidManifest.xml | 26 - .../java/com/example/observer/MainActivity.kt | 17 - .../java/com/example/observer/ObserverApp.kt | 26 - .../observer/camera/CameraController.kt | 82 - .../example/observer/camera/FrameAnalyzer.kt | 38 - .../example/observer/camera/MotionDetector.kt | 60 - .../observer/data/SettingsRepository.kt | 75 - .../observer/detection/CoordinateMapper.kt | 54 - .../observer/detection/DetectionResult.kt | 21 - .../example/observer/detection/Detector.kt | 8 - .../observer/detection/MotionAggregator.kt | 103 - .../observer/detection/MotionRegion.kt | 12 - .../com/example/observer/detection/Nms.kt | 21 - .../observer/detection/TFLiteDetector.kt | 130 - .../observer/distance/DistanceEstimator.kt | 68 - .../observer/overlay/DetectionOverlay.kt | 79 - .../com/example/observer/reminder/Reminder.kt | 74 - .../com/example/observer/ui/ObserverApp.kt | 22 - .../observer/ui/camera/CameraScreen.kt | 244 -- .../observer/ui/camera/CameraViewModel.kt | 189 -- .../observer/ui/settings/SettingsScreen.kt | 109 - .../com/example/observer/ui/theme/Theme.kt | 21 - app/src/main/res/drawable/ic_launcher.xml | 21 - app/src/main/res/values/strings.xml | 3 - app/src/main/res/values/themes.xml | 7 - .../detection/CoordinateMapperTest.kt | 61 - .../detection/MotionAggregatorTest.kt | 66 - .../com/example/observer/detection/NmsTest.kt | 50 - build.gradle.kts | 5 - docs/01-技术方案.md | 320 --- docs/02-项目功能文档.md | 193 -- docs/03-技术实现文档.md | 512 ---- flutter_app/.gitignore | 45 + flutter_app/.metadata | 30 + flutter_app/README.md | 33 + flutter_app/analysis_options.yaml | 28 + flutter_app/android/.gitignore | 14 + flutter_app/android/app/build.gradle.kts | 53 + .../android/app/src/debug/AndroidManifest.xml | 7 + .../android/app/src/main/AndroidManifest.xml | 48 + .../com/example/observer/MainActivity.kt | 5 + .../res/drawable-v21/launch_background.xml | 12 + .../main/res/drawable/launch_background.xml | 12 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 544 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 442 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 721 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 1031 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 1443 bytes .../app/src/main/res/values-night/styles.xml | 18 + .../app/src/main/res/values/styles.xml | 18 + .../app/src/profile/AndroidManifest.xml | 7 + flutter_app/android/build.gradle.kts | 24 + flutter_app/android/gradle.properties | 6 + .../gradle}/wrapper/gradle-wrapper.properties | 6 +- flutter_app/android/settings.gradle.kts | 26 + flutter_app/assets/beep.wav | Bin 0 -> 12392 bytes .../main => flutter_app}/assets/labels.txt | 2 +- flutter_app/docs/PaymentApi.md | 103 + flutter_app/ios/.gitignore | 34 + .../ios/Flutter/AppFrameworkInfo.plist | 24 + flutter_app/ios/Flutter/Debug.xcconfig | 2 + flutter_app/ios/Flutter/Release.xcconfig | 2 + flutter_app/ios/Podfile | 66 + flutter_app/ios/Podfile.lock | 83 + .../ios/Runner.xcodeproj/project.pbxproj | 782 ++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../xcshareddata/xcschemes/Runner.xcscheme | 119 + .../contents.xcworkspacedata | 10 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + flutter_app/ios/Runner/AppDelegate.swift | 16 + .../AppIcon.appiconset/Contents.json | 122 + .../Icon-App-1024x1024@1x.png | Bin 0 -> 10932 bytes .../AppIcon.appiconset/Icon-App-20x20@1x.png | Bin 0 -> 295 bytes .../AppIcon.appiconset/Icon-App-20x20@2x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-20x20@3x.png | Bin 0 -> 450 bytes .../AppIcon.appiconset/Icon-App-29x29@1x.png | Bin 0 -> 282 bytes .../AppIcon.appiconset/Icon-App-29x29@2x.png | Bin 0 -> 462 bytes .../AppIcon.appiconset/Icon-App-29x29@3x.png | Bin 0 -> 704 bytes .../AppIcon.appiconset/Icon-App-40x40@1x.png | Bin 0 -> 406 bytes .../AppIcon.appiconset/Icon-App-40x40@2x.png | Bin 0 -> 586 bytes .../AppIcon.appiconset/Icon-App-40x40@3x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@2x.png | Bin 0 -> 862 bytes .../AppIcon.appiconset/Icon-App-60x60@3x.png | Bin 0 -> 1674 bytes .../AppIcon.appiconset/Icon-App-76x76@1x.png | Bin 0 -> 762 bytes .../AppIcon.appiconset/Icon-App-76x76@2x.png | Bin 0 -> 1226 bytes .../Icon-App-83.5x83.5@2x.png | Bin 0 -> 1418 bytes .../LaunchImage.imageset/Contents.json | 23 + .../LaunchImage.imageset/LaunchImage.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@2x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/LaunchImage@3x.png | Bin 0 -> 68 bytes .../LaunchImage.imageset/README.md | 5 + .../Runner/Base.lproj/LaunchScreen.storyboard | 37 + .../ios/Runner/Base.lproj/Main.storyboard | 29 + flutter_app/ios/Runner/Info.plist | 108 + .../ios/Runner/Runner-Bridging-Header.h | 1 + flutter_app/ios/Runner/Runner.entitlements | 6 + flutter_app/ios/Runner/SceneDelegate.swift | 6 + flutter_app/ios/RunnerTests/RunnerTests.swift | 12 + flutter_app/lib/app.dart | 73 + flutter_app/lib/auth/auth_screen.dart | 137 ++ flutter_app/lib/auth/auth_view_model.dart | 57 + flutter_app/lib/auth/session_store.dart | 36 + .../lib/camera/app_camera_controller.dart | 92 + flutter_app/lib/camera/camera_screen.dart | 389 +++ flutter_app/lib/camera/camera_view_model.dart | 238 ++ flutter_app/lib/camera/detection_overlay.dart | 148 ++ flutter_app/lib/camera/frame_analyzer.dart | 97 + flutter_app/lib/camera/motion_detector.dart | 53 + flutter_app/lib/config/app_config.dart | 22 + flutter_app/lib/container.dart | 35 + .../lib/detection/background_model.dart | 94 + .../lib/detection/coordinate_mapper.dart | 69 + .../lib/detection/detection_result.dart | 56 + .../lib/detection/detector_worker.dart | 265 +++ .../lib/detection/motion_aggregator.dart | 96 + flutter_app/lib/detection/nms.dart | 21 + .../lib/detection/tflite_detector.dart | 312 +++ flutter_app/lib/detection/visual_prior.dart | 113 + flutter_app/lib/home/home_screen.dart | 216 ++ flutter_app/lib/home/home_view_model.dart | 67 + flutter_app/lib/main.dart | 27 + flutter_app/lib/payment/license_service.dart | 79 + flutter_app/lib/payment/models.dart | 67 + flutter_app/lib/payment/order_api.dart | 170 ++ flutter_app/lib/payment/payment_service.dart | 108 + flutter_app/lib/payment/paywall_screen.dart | 221 ++ .../lib/payment/paywall_view_model.dart | 120 + flutter_app/lib/reminder/reminder.dart | 34 + flutter_app/pubspec.lock | 882 +++++++ flutter_app/pubspec.yaml | 50 + flutter_app/test/auth_flow_test.dart | 154 ++ flutter_app/test/coordinate_mapper_test.dart | 49 + flutter_app/test/motion_aggregator_test.dart | 68 + flutter_app/test/nms_test.dart | 43 + flutter_app/test/widget_test.dart | 37 + gradle.properties | 4 - gradle/libs.versions.toml | 39 - gradle/wrapper/gradle-wrapper.jar | Bin 47505 -> 0 bytes gradlew | 248 -- gradlew.bat | 82 - server/CLAUDE.md | 110 + server/Dockerfile | 23 + server/README.md | 158 ++ server/biz/consts/consts.go | 22 + server/biz/controller/admin.go | 35 + server/biz/controller/auth.go | 23 + server/biz/controller/license.go | 18 + server/biz/controller/order.go | 28 + server/biz/controller/payment.go | 55 + server/biz/dao/license.go | 134 ++ server/biz/dao/payment_order.go | 166 ++ server/biz/model/dto/admin.go | 81 + server/biz/model/dto/auth.go | 24 + server/biz/model/dto/license.go | 17 + server/biz/model/dto/order.go | 54 + server/biz/model/dto/payment.go | 24 + server/biz/model/entity/license.go | 13 + server/biz/model/entity/payment_order.go | 17 + server/biz/service/admin_test.go | 211 ++ server/biz/service/auth.go | 66 + server/biz/service/license.go | 104 + server/biz/service/order.go | 161 ++ server/biz/service/payment.go | 380 +++ server/biz/service/payment_test.go | 248 ++ server/biz/service/testdata/config.yml | 26 + server/common/admin_auth.go | 23 + server/common/auth.go | 126 + server/common/base_dao.go | 89 + server/common/bind_controller.go | 9 + server/common/errors.go | 14 + server/common/page.go | 15 + server/common/plans.go | 37 + server/common/pool.go | 52 + server/common/response.go | 30 + server/common/serial_writer.go | 45 + server/common/spa.go | 34 + server/common/sqlite.go | 5 + server/config.yml | 54 + server/docker-compose.yml | 12 + server/go.mod | 51 + server/go.sum | 128 + server/main.go | 95 + server/技术设计.md | 167 ++ server_admin/.gitignore | 28 + server_admin/.vscode/extensions.json | 3 + server_admin/README.md | 5 + server_admin/index.html | 12 + server_admin/package-lock.json | 2097 +++++++++++++++++ server_admin/package.json | 22 + server_admin/public/favicon.svg | 1 + server_admin/public/icons.svg | 24 + server_admin/src/App.vue | 89 + server_admin/src/api/request.js | 45 + server_admin/src/assets/hero.png | Bin 0 -> 13057 bytes server_admin/src/assets/vite.svg | 1 + server_admin/src/main.js | 9 + server_admin/src/router/index.js | 29 + server_admin/src/style.css | 17 + server_admin/src/views/Licenses.vue | 163 ++ server_admin/src/views/Login.vue | 86 + server_admin/src/views/Orders.vue | 139 ++ server_admin/vite.config.js | 18 + settings.gradle.kts | 24 - .../analyze_rfdetr.cpython-310.pyc | Bin 0 -> 12276 bytes .../analyze_rfdetr.cpython-314.pyc | Bin 0 -> 23337 bytes training/analyze_rfdetr.py | 278 +++ training/dump_graph.py | 139 ++ training/gen_images_config.json | 29 - training/inspect_tflite.py | 94 + training/prepare_yolo.py | 48 + training/train_local.py | 21 + training/train_server.py | 18 + 218 files changed, 13391 insertions(+), 3232 deletions(-) delete mode 100644 app/build.gradle.kts delete mode 100644 app/proguard-rules.pro delete mode 100644 app/src/main/AndroidManifest.xml delete mode 100644 app/src/main/java/com/example/observer/MainActivity.kt delete mode 100644 app/src/main/java/com/example/observer/ObserverApp.kt delete mode 100644 app/src/main/java/com/example/observer/camera/CameraController.kt delete mode 100644 app/src/main/java/com/example/observer/camera/FrameAnalyzer.kt delete mode 100644 app/src/main/java/com/example/observer/camera/MotionDetector.kt delete mode 100644 app/src/main/java/com/example/observer/data/SettingsRepository.kt delete mode 100644 app/src/main/java/com/example/observer/detection/CoordinateMapper.kt delete mode 100644 app/src/main/java/com/example/observer/detection/DetectionResult.kt delete mode 100644 app/src/main/java/com/example/observer/detection/Detector.kt delete mode 100644 app/src/main/java/com/example/observer/detection/MotionAggregator.kt delete mode 100644 app/src/main/java/com/example/observer/detection/MotionRegion.kt delete mode 100644 app/src/main/java/com/example/observer/detection/Nms.kt delete mode 100644 app/src/main/java/com/example/observer/detection/TFLiteDetector.kt delete mode 100644 app/src/main/java/com/example/observer/distance/DistanceEstimator.kt delete mode 100644 app/src/main/java/com/example/observer/overlay/DetectionOverlay.kt delete mode 100644 app/src/main/java/com/example/observer/reminder/Reminder.kt delete mode 100644 app/src/main/java/com/example/observer/ui/ObserverApp.kt delete mode 100644 app/src/main/java/com/example/observer/ui/camera/CameraScreen.kt delete mode 100644 app/src/main/java/com/example/observer/ui/camera/CameraViewModel.kt delete mode 100644 app/src/main/java/com/example/observer/ui/settings/SettingsScreen.kt delete mode 100644 app/src/main/java/com/example/observer/ui/theme/Theme.kt delete mode 100644 app/src/main/res/drawable/ic_launcher.xml delete mode 100644 app/src/main/res/values/strings.xml delete mode 100644 app/src/main/res/values/themes.xml delete mode 100644 app/src/test/java/com/example/observer/detection/CoordinateMapperTest.kt delete mode 100644 app/src/test/java/com/example/observer/detection/MotionAggregatorTest.kt delete mode 100644 app/src/test/java/com/example/observer/detection/NmsTest.kt delete mode 100644 build.gradle.kts delete mode 100644 docs/01-技术方案.md delete mode 100644 docs/02-项目功能文档.md delete mode 100644 docs/03-技术实现文档.md create mode 100644 flutter_app/.gitignore create mode 100644 flutter_app/.metadata create mode 100644 flutter_app/README.md create mode 100644 flutter_app/analysis_options.yaml create mode 100644 flutter_app/android/.gitignore create mode 100644 flutter_app/android/app/build.gradle.kts create mode 100644 flutter_app/android/app/src/debug/AndroidManifest.xml create mode 100644 flutter_app/android/app/src/main/AndroidManifest.xml create mode 100644 flutter_app/android/app/src/main/kotlin/com/example/observer/MainActivity.kt create mode 100644 flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml create mode 100644 flutter_app/android/app/src/main/res/drawable/launch_background.xml create mode 100644 flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 flutter_app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 flutter_app/android/app/src/main/res/values-night/styles.xml create mode 100644 flutter_app/android/app/src/main/res/values/styles.xml create mode 100644 flutter_app/android/app/src/profile/AndroidManifest.xml create mode 100644 flutter_app/android/build.gradle.kts create mode 100644 flutter_app/android/gradle.properties rename {gradle => flutter_app/android/gradle}/wrapper/gradle-wrapper.properties (66%) create mode 100644 flutter_app/android/settings.gradle.kts create mode 100644 flutter_app/assets/beep.wav rename {app/src/main => flutter_app}/assets/labels.txt (52%) create mode 100644 flutter_app/docs/PaymentApi.md create mode 100644 flutter_app/ios/.gitignore create mode 100644 flutter_app/ios/Flutter/AppFrameworkInfo.plist create mode 100644 flutter_app/ios/Flutter/Debug.xcconfig create mode 100644 flutter_app/ios/Flutter/Release.xcconfig create mode 100644 flutter_app/ios/Podfile create mode 100644 flutter_app/ios/Podfile.lock create mode 100644 flutter_app/ios/Runner.xcodeproj/project.pbxproj create mode 100644 flutter_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 flutter_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 flutter_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 flutter_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme create mode 100644 flutter_app/ios/Runner.xcworkspace/contents.xcworkspacedata create mode 100644 flutter_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 flutter_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 flutter_app/ios/Runner/AppDelegate.swift create mode 100644 flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png create mode 100644 flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png create mode 100644 flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png create mode 100644 flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png create mode 100644 flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png create mode 100644 flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png create mode 100644 flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png create mode 100644 flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png create mode 100644 flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png create mode 100644 flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png create mode 100644 flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png create mode 100644 flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png create mode 100644 flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png create mode 100644 flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png create mode 100644 flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png create mode 100644 flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json create mode 100644 flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png create mode 100644 flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png create mode 100644 flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png create mode 100644 flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md create mode 100644 flutter_app/ios/Runner/Base.lproj/LaunchScreen.storyboard create mode 100644 flutter_app/ios/Runner/Base.lproj/Main.storyboard create mode 100644 flutter_app/ios/Runner/Info.plist create mode 100644 flutter_app/ios/Runner/Runner-Bridging-Header.h create mode 100644 flutter_app/ios/Runner/Runner.entitlements create mode 100644 flutter_app/ios/Runner/SceneDelegate.swift create mode 100644 flutter_app/ios/RunnerTests/RunnerTests.swift create mode 100644 flutter_app/lib/app.dart create mode 100644 flutter_app/lib/auth/auth_screen.dart create mode 100644 flutter_app/lib/auth/auth_view_model.dart create mode 100644 flutter_app/lib/auth/session_store.dart create mode 100644 flutter_app/lib/camera/app_camera_controller.dart create mode 100644 flutter_app/lib/camera/camera_screen.dart create mode 100644 flutter_app/lib/camera/camera_view_model.dart create mode 100644 flutter_app/lib/camera/detection_overlay.dart create mode 100644 flutter_app/lib/camera/frame_analyzer.dart create mode 100644 flutter_app/lib/camera/motion_detector.dart create mode 100644 flutter_app/lib/config/app_config.dart create mode 100644 flutter_app/lib/container.dart create mode 100644 flutter_app/lib/detection/background_model.dart create mode 100644 flutter_app/lib/detection/coordinate_mapper.dart create mode 100644 flutter_app/lib/detection/detection_result.dart create mode 100644 flutter_app/lib/detection/detector_worker.dart create mode 100644 flutter_app/lib/detection/motion_aggregator.dart create mode 100644 flutter_app/lib/detection/nms.dart create mode 100644 flutter_app/lib/detection/tflite_detector.dart create mode 100644 flutter_app/lib/detection/visual_prior.dart create mode 100644 flutter_app/lib/home/home_screen.dart create mode 100644 flutter_app/lib/home/home_view_model.dart create mode 100644 flutter_app/lib/main.dart create mode 100644 flutter_app/lib/payment/license_service.dart create mode 100644 flutter_app/lib/payment/models.dart create mode 100644 flutter_app/lib/payment/order_api.dart create mode 100644 flutter_app/lib/payment/payment_service.dart create mode 100644 flutter_app/lib/payment/paywall_screen.dart create mode 100644 flutter_app/lib/payment/paywall_view_model.dart create mode 100644 flutter_app/lib/reminder/reminder.dart create mode 100644 flutter_app/pubspec.lock create mode 100644 flutter_app/pubspec.yaml create mode 100644 flutter_app/test/auth_flow_test.dart create mode 100644 flutter_app/test/coordinate_mapper_test.dart create mode 100644 flutter_app/test/motion_aggregator_test.dart create mode 100644 flutter_app/test/nms_test.dart create mode 100644 flutter_app/test/widget_test.dart delete mode 100644 gradle.properties delete mode 100644 gradle/libs.versions.toml delete mode 100644 gradle/wrapper/gradle-wrapper.jar delete mode 100755 gradlew delete mode 100644 gradlew.bat create mode 100644 server/CLAUDE.md create mode 100644 server/Dockerfile create mode 100644 server/README.md create mode 100644 server/biz/consts/consts.go create mode 100644 server/biz/controller/admin.go create mode 100644 server/biz/controller/auth.go create mode 100644 server/biz/controller/license.go create mode 100644 server/biz/controller/order.go create mode 100644 server/biz/controller/payment.go create mode 100644 server/biz/dao/license.go create mode 100644 server/biz/dao/payment_order.go create mode 100644 server/biz/model/dto/admin.go create mode 100644 server/biz/model/dto/auth.go create mode 100644 server/biz/model/dto/license.go create mode 100644 server/biz/model/dto/order.go create mode 100644 server/biz/model/dto/payment.go create mode 100644 server/biz/model/entity/license.go create mode 100644 server/biz/model/entity/payment_order.go create mode 100644 server/biz/service/admin_test.go create mode 100644 server/biz/service/auth.go create mode 100644 server/biz/service/license.go create mode 100644 server/biz/service/order.go create mode 100644 server/biz/service/payment.go create mode 100644 server/biz/service/payment_test.go create mode 100644 server/biz/service/testdata/config.yml create mode 100644 server/common/admin_auth.go create mode 100644 server/common/auth.go create mode 100644 server/common/base_dao.go create mode 100644 server/common/bind_controller.go create mode 100644 server/common/errors.go create mode 100644 server/common/page.go create mode 100644 server/common/plans.go create mode 100644 server/common/pool.go create mode 100644 server/common/response.go create mode 100644 server/common/serial_writer.go create mode 100644 server/common/spa.go create mode 100644 server/common/sqlite.go create mode 100644 server/config.yml create mode 100644 server/docker-compose.yml create mode 100644 server/go.mod create mode 100644 server/go.sum create mode 100644 server/main.go create mode 100644 server/技术设计.md create mode 100644 server_admin/.gitignore create mode 100644 server_admin/.vscode/extensions.json create mode 100644 server_admin/README.md create mode 100644 server_admin/index.html create mode 100644 server_admin/package-lock.json create mode 100644 server_admin/package.json create mode 100644 server_admin/public/favicon.svg create mode 100644 server_admin/public/icons.svg create mode 100644 server_admin/src/App.vue create mode 100644 server_admin/src/api/request.js create mode 100644 server_admin/src/assets/hero.png create mode 100644 server_admin/src/assets/vite.svg create mode 100644 server_admin/src/main.js create mode 100644 server_admin/src/router/index.js create mode 100644 server_admin/src/style.css create mode 100644 server_admin/src/views/Licenses.vue create mode 100644 server_admin/src/views/Login.vue create mode 100644 server_admin/src/views/Orders.vue create mode 100644 server_admin/vite.config.js delete mode 100644 settings.gradle.kts create mode 100644 training/__pycache__/analyze_rfdetr.cpython-310.pyc create mode 100644 training/__pycache__/analyze_rfdetr.cpython-314.pyc create mode 100644 training/analyze_rfdetr.py create mode 100644 training/dump_graph.py delete mode 100644 training/gen_images_config.json create mode 100644 training/inspect_tflite.py create mode 100644 training/prepare_yolo.py create mode 100644 training/train_local.py create mode 100644 training/train_server.py diff --git a/.gitignore b/.gitignore index df6af72..df8341d 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,16 @@ captures/ .DS_Store training/datasets/ training/venv/ +training/runs/ *.pt +*.tflite + +# 后端运行时数据(SQLite 库,删除即丢授权/订单) +server/data/ +server/biz/service/testdata/*.db + +# 管理端构建产物(server_admin 构建生成,由后端托管) +server/admin_dist/ # 文生图配置(含API key,不入库) #training/gen_images_config.json diff --git a/app/build.gradle.kts b/app/build.gradle.kts deleted file mode 100644 index 2767ee3..0000000 --- a/app/build.gradle.kts +++ /dev/null @@ -1,80 +0,0 @@ -plugins { - alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.compose) -} - -android { - namespace = "com.example.observer" - compileSdk = 35 - - defaultConfig { - applicationId = "com.example.observer" - minSdk = 24 - targetSdk = 35 - versionCode = 1 - versionName = "1.0" - - ndk { - abiFilters += listOf("arm64-v8a", "armeabi-v7a") - } - } - - buildTypes { - release { - isMinifyEnabled = true - isShrinkResources = true - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro", - ) - } - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = "17" - } - - buildFeatures { - compose = true - } - - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } -} - -dependencies { - implementation(libs.androidx.core.ktx) - implementation(libs.androidx.lifecycle.runtime.ktx) - implementation(libs.androidx.lifecycle.viewmodel.compose) - implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.activity.compose) - - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.ui.graphics) - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.androidx.compose.material3) - debugImplementation(libs.androidx.compose.ui.tooling) - - implementation(libs.androidx.camera.core) - implementation(libs.androidx.camera.camera2) - implementation(libs.androidx.camera.lifecycle) - implementation(libs.androidx.camera.view) - - implementation(libs.org.tensorflow.lite) - implementation(libs.org.tensorflow.lite.gpu) - - implementation(libs.androidx.datastore.preferences) - implementation(libs.kotlinx.coroutines.android) - - testImplementation(libs.junit) -} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro deleted file mode 100644 index 2927948..0000000 --- a/app/proguard-rules.pro +++ /dev/null @@ -1,3 +0,0 @@ -# TensorFlow Lite --keep class org.tensorflow.** { *; } --dontwarn org.tensorflow.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml deleted file mode 100644 index 6879cfb..0000000 --- a/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/app/src/main/java/com/example/observer/MainActivity.kt b/app/src/main/java/com/example/observer/MainActivity.kt deleted file mode 100644 index 6366940..0000000 --- a/app/src/main/java/com/example/observer/MainActivity.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.example.observer - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge -import com.example.observer.ui.ObserverApp - -class MainActivity : ComponentActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - enableEdgeToEdge() - super.onCreate(savedInstanceState) - setContent { - ObserverApp() - } - } -} diff --git a/app/src/main/java/com/example/observer/ObserverApp.kt b/app/src/main/java/com/example/observer/ObserverApp.kt deleted file mode 100644 index d9fb221..0000000 --- a/app/src/main/java/com/example/observer/ObserverApp.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.example.observer - -import android.app.Application -import android.content.Context -import com.example.observer.data.SettingsRepository -import com.example.observer.detection.Detector -import com.example.observer.detection.TFLiteDetector -import com.example.observer.distance.DistanceEstimator -import com.example.observer.reminder.Reminder - -class ObserverApp : Application() { - lateinit var container: AppContainer - private set - - override fun onCreate() { - super.onCreate() - container = AppContainer(this) - } -} - -class AppContainer(context: Context) { - val settingsRepository = SettingsRepository(context) - val detector: Detector? = TFLiteDetector.create(context) - val distanceEstimator = DistanceEstimator(context) - val reminder = Reminder(context) -} diff --git a/app/src/main/java/com/example/observer/camera/CameraController.kt b/app/src/main/java/com/example/observer/camera/CameraController.kt deleted file mode 100644 index c5ec446..0000000 --- a/app/src/main/java/com/example/observer/camera/CameraController.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.example.observer.camera - -import android.util.Log -import androidx.camera.core.Camera -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.Preview -import androidx.camera.camera2.interop.Camera2CameraInfo -import androidx.camera.lifecycle.ProcessCameraProvider -import androidx.camera.view.PreviewView -import androidx.core.content.ContextCompat -import androidx.lifecycle.LifecycleOwner -import java.util.concurrent.Executor - -class CameraController( - private val lifecycleOwner: LifecycleOwner, - private val analysisExecutor: Executor, -) { - var onCameraIdChanged: ((String) -> Unit)? = null - - var onInitError: (() -> Unit)? = null - - var currentCameraId: String? = null - private set - - private var cameraProvider: ProcessCameraProvider? = null - private var isFrontFacing = false - - fun start(previewView: PreviewView, analyzer: ImageAnalysis.Analyzer?) { - val future = ProcessCameraProvider.getInstance(previewView.context) - future.addListener({ - try { - val provider = future.get() - cameraProvider = provider - bind(previewView, analyzer) - } catch (e: Exception) { - Log.e(TAG, "相机初始化失败", e) - onInitError?.invoke() - } - }, ContextCompat.getMainExecutor(previewView.context)) - } - - fun switchCamera(previewView: PreviewView, analyzer: ImageAnalysis.Analyzer?) { - isFrontFacing = !isFrontFacing - bind(previewView, analyzer) - } - - private fun bind(previewView: PreviewView, analyzer: ImageAnalysis.Analyzer?) { - val provider = cameraProvider ?: return - provider.unbindAll() - - val preview = Preview.Builder().build().also { - it.surfaceProvider = previewView.surfaceProvider - } - val imageAnalysis = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888) - .build() - analyzer?.let { imageAnalysis.setAnalyzer(analysisExecutor, it) } - - val camera: Camera = provider.bindToLifecycle( - lifecycleOwner, - currentSelector(), - preview, - imageAnalysis, - ) - currentCameraId = try { - Camera2CameraInfo.from(camera.cameraInfo).cameraId - } catch (e: Exception) { - Log.w(TAG, "获取 cameraId 失败", e) - null - } - currentCameraId?.let { onCameraIdChanged?.invoke(it) } - } - - private fun currentSelector(): CameraSelector = - if (isFrontFacing) CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA - - companion object { - private const val TAG = "CameraController" - } -} diff --git a/app/src/main/java/com/example/observer/camera/FrameAnalyzer.kt b/app/src/main/java/com/example/observer/camera/FrameAnalyzer.kt deleted file mode 100644 index 75d9a15..0000000 --- a/app/src/main/java/com/example/observer/camera/FrameAnalyzer.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.example.observer.camera - -import android.os.SystemClock -import androidx.camera.core.ImageAnalysis -import androidx.camera.core.ImageProxy -import com.example.observer.detection.DetectionResult -import com.example.observer.detection.Detector -import com.example.observer.detection.MotionRegion - -class FrameAnalyzer( - private val detector: Detector, - private val motionDetector: MotionDetector, - private val onResult: (results: List, rotation: Int, imageWidthPx: Int, imageHeightPx: Int, motionRegions: List) -> Unit, -) : ImageAnalysis.Analyzer { - - @Volatile var intervalMs: Long = 100L - - private var lastDetectMs = 0L - - override fun analyze(imageProxy: ImageProxy) { - val now = SystemClock.elapsedRealtime() - if (now - lastDetectMs < intervalMs) { - imageProxy.close() - return - } - lastDetectMs = now - - val bitmap = imageProxy.toBitmap() - val rotation = imageProxy.imageInfo.rotationDegrees - try { - val motionRegions = motionDetector.detectMotion(bitmap) - val results = detector.detect(bitmap) - onResult(results, rotation, bitmap.width, bitmap.height, motionRegions) - } finally { - imageProxy.close() - } - } -} diff --git a/app/src/main/java/com/example/observer/camera/MotionDetector.kt b/app/src/main/java/com/example/observer/camera/MotionDetector.kt deleted file mode 100644 index e173899..0000000 --- a/app/src/main/java/com/example/observer/camera/MotionDetector.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.example.observer.camera - -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Paint -import android.graphics.Rect -import com.example.observer.detection.MotionAggregator -import com.example.observer.detection.MotionRegion -import kotlin.math.min - -/** - * 轻量运动检测:相邻帧灰度差分 + 分块聚合。 - * 小尺寸工作(约 128x128 内),每帧开销亚毫秒级,在分析线程串行调用。 - * 相机大幅移动时(全屏帧差)自动忽略本帧,避免误报。 - */ -class MotionDetector( - private val maxWidth: Int = 128, - private val maxHeight: Int = 128, -) { - - private val smallBitmap = Bitmap.createBitmap(maxWidth, maxHeight, Bitmap.Config.ARGB_8888) - private val canvas = Canvas(smallBitmap) - private val paint = Paint(Paint.FILTER_BITMAP_FLAG) - - private var prevGray: IntArray? = null - private var grayCache: IntArray = IntArray(0) - - fun detectMotion(bitmap: Bitmap): List { - val scale = min(maxWidth.toFloat() / bitmap.width, maxHeight.toFloat() / bitmap.height) - val w = (bitmap.width * scale).toInt() - val h = (bitmap.height * scale).toInt() - if (w == 0 || h == 0) return emptyList() - - canvas.drawBitmap(bitmap, null, Rect(0, 0, w, h), paint) - if (grayCache.size != w * h) grayCache = IntArray(w * h) - smallBitmap.getPixels(grayCache, 0, w, 0, 0, w, h) - - val prev = prevGray - prevGray = grayCache.copyOf() - if (prev == null || prev.size != grayCache.size) return emptyList() - - // 取绿色通道近似亮度 - val gray = IntArray(grayCache.size) - for (i in grayCache.indices) gray[i] = (grayCache[i] shr 8) and 0xFF - val prevGreen = IntArray(prev.size) - for (i in prev.indices) prevGreen[i] = (prev[i] shr 8) and 0xFF - - val diff = MotionAggregator.diffMask(gray, prevGreen) - val motionTotal = diff.sum() - // 全屏大差异 → 相机移动/大范围变化,忽略本帧 - if (motionTotal > w * h / 2) return emptyList() - if (motionTotal < 12) return emptyList() - return MotionAggregator.aggregate(diff, w, h) - } - - /** 相机切换后重置参考帧,避免旧帧误差 */ - fun reset() { - prevGray = null - } -} diff --git a/app/src/main/java/com/example/observer/data/SettingsRepository.kt b/app/src/main/java/com/example/observer/data/SettingsRepository.kt deleted file mode 100644 index 9ae6eb0..0000000 --- a/app/src/main/java/com/example/observer/data/SettingsRepository.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.example.observer.data - -import android.content.Context -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.floatPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map - -enum class DetectMode(val intervalMs: Long, val label: String) { - CONTINUOUS(100L, "连续"), - STANDARD(300L, "标准"), - POWER_SAVING(1000L, "省电"), -} - -data class AppSettings( - val confThreshold: Float = 0.40f, - val habitatThreshold: Float = 0.35f, - val detectMode: DetectMode = DetectMode.CONTINUOUS, - val vibrateEnabled: Boolean = true, - val soundEnabled: Boolean = true, - val showDistance: Boolean = true, - val habitatAlertEnabled: Boolean = true, -) - -private val Context.settingsDataStore by preferencesDataStore(name = "settings") - -class SettingsRepository(private val context: Context) { - - val settings: Flow = context.settingsDataStore.data.map { prefs -> - AppSettings( - confThreshold = prefs[KEY_CONF] ?: 0.40f, - habitatThreshold = prefs[KEY_HABITAT] ?: 0.35f, - detectMode = DetectMode.entries.firstOrNull { it.name == prefs[KEY_MODE] } - ?: DetectMode.CONTINUOUS, - vibrateEnabled = prefs[KEY_VIBRATE] ?: true, - soundEnabled = prefs[KEY_SOUND] ?: true, - showDistance = prefs[KEY_DISTANCE] ?: true, - habitatAlertEnabled = prefs[KEY_HABITAT_ALERT] ?: true, - ) - } - - suspend fun setConfThreshold(value: Float) = - context.settingsDataStore.edit { it[KEY_CONF] = value } - - suspend fun setHabitatThreshold(value: Float) = - context.settingsDataStore.edit { it[KEY_HABITAT] = value } - - suspend fun setDetectMode(mode: DetectMode) = - context.settingsDataStore.edit { it[KEY_MODE] = mode.name } - - suspend fun setVibrateEnabled(value: Boolean) = - context.settingsDataStore.edit { it[KEY_VIBRATE] = value } - - suspend fun setSoundEnabled(value: Boolean) = - context.settingsDataStore.edit { it[KEY_SOUND] = value } - - suspend fun setShowDistance(value: Boolean) = - context.settingsDataStore.edit { it[KEY_DISTANCE] = value } - - suspend fun setHabitatAlertEnabled(value: Boolean) = - context.settingsDataStore.edit { it[KEY_HABITAT_ALERT] = value } - - companion object { - private val KEY_CONF = floatPreferencesKey("conf_threshold") - private val KEY_HABITAT = floatPreferencesKey("habitat_threshold") - private val KEY_MODE = stringPreferencesKey("detect_mode") - private val KEY_VIBRATE = booleanPreferencesKey("vibrate_enabled") - private val KEY_SOUND = booleanPreferencesKey("sound_enabled") - private val KEY_DISTANCE = booleanPreferencesKey("show_distance") - private val KEY_HABITAT_ALERT = booleanPreferencesKey("habitat_alert_enabled") - } -} diff --git a/app/src/main/java/com/example/observer/detection/CoordinateMapper.kt b/app/src/main/java/com/example/observer/detection/CoordinateMapper.kt deleted file mode 100644 index dcd84ec..0000000 --- a/app/src/main/java/com/example/observer/detection/CoordinateMapper.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.example.observer.detection - -/** 预览视图像素坐标矩形(纯 JVM 类型,便于单元测试) */ -data class ViewRect( - val left: Float, - val top: Float, - val right: Float, - val bottom: Float, -) { - val width: Float get() = right - left - val height: Float get() = bottom - top - val centerX: Float get() = (left + right) / 2f - val centerY: Float get() = (top + bottom) / 2f -} - -object CoordinateMapper { - - /** 模型归一化坐标 → 预览视图坐标(含传感器旋转与 FIT_CENTER 裁剪) */ - fun mapToView( - normLeft: Float, - normTop: Float, - normRight: Float, - normBottom: Float, - rotation: Int, // ImageProxy.imageInfo.rotationDegrees - imageW: Int, - imageH: Int, // 分析图像尺寸(横屏原图) - viewW: Int, - viewH: Int, // 预览视图尺寸 - ): ViewRect { - // 1) 旋转校正:图像方向 → 竖屏视图方向(归一化坐标) - val (x0, y0, x1, y1) = when (rotation) { - 90 -> Quad(1 - normBottom, normLeft, 1 - normTop, normRight) - 180 -> Quad(1 - normRight, 1 - normBottom, 1 - normLeft, 1 - normTop) - 270 -> Quad(normTop, 1 - normRight, normBottom, 1 - normLeft) - else -> Quad(normLeft, normTop, normRight, normBottom) - } - // 2) 旋转后图像在竖屏方向上的尺寸 - val portrait = rotation == 90 || rotation == 270 - val portW = if (portrait) imageH else imageW - val portH = if (portrait) imageW else imageH - // 3) FIT_CENTER 缩放与居中偏移 - val scale = minOf(viewW.toFloat() / portW, viewH.toFloat() / portH) - val offsetX = (viewW - portW * scale) / 2f - val offsetY = (viewH - portH * scale) / 2f - return ViewRect( - left = x0 * portW * scale + offsetX, - top = y0 * portH * scale + offsetY, - right = x1 * portW * scale + offsetX, - bottom = y1 * portH * scale + offsetY, - ) - } - - private data class Quad(val x0: Float, val y0: Float, val x1: Float, val y1: Float) -} diff --git a/app/src/main/java/com/example/observer/detection/DetectionResult.kt b/app/src/main/java/com/example/observer/detection/DetectionResult.kt deleted file mode 100644 index 0d49bc3..0000000 --- a/app/src/main/java/com/example/observer/detection/DetectionResult.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.example.observer.detection - -data class DetectionResult( - val label: String, - val score: Float, - val left: Float, - val top: Float, - val right: Float, - val bottom: Float, - val distanceM: Float? = null, -) { - val width: Float get() = right - left - val height: Float get() = bottom - top - val centerX: Float get() = (left + right) / 2f - val centerY: Float get() = (top + bottom) / 2f - val isHabitat: Boolean get() = label == HABITAT_LABEL - - companion object { - const val HABITAT_LABEL = "cover" - } -} diff --git a/app/src/main/java/com/example/observer/detection/Detector.kt b/app/src/main/java/com/example/observer/detection/Detector.kt deleted file mode 100644 index 4c4df00..0000000 --- a/app/src/main/java/com/example/observer/detection/Detector.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.example.observer.detection - -import android.graphics.Bitmap - -interface Detector { - /** 输入 RGBA 位图,输出归一化坐标检测结果(0..1) */ - fun detect(bitmap: Bitmap): List -} diff --git a/app/src/main/java/com/example/observer/detection/MotionAggregator.kt b/app/src/main/java/com/example/observer/detection/MotionAggregator.kt deleted file mode 100644 index 0ead024..0000000 --- a/app/src/main/java/com/example/observer/detection/MotionAggregator.kt +++ /dev/null @@ -1,103 +0,0 @@ -package com.example.observer.detection - -import kotlin.math.abs - -/** - * 帧差运动聚合(纯 JVM,可单测)。 - * 输入:每像素 0/1 差分掩码,按 8x8 分块统计激活块,连通块聚合为运动区域。 - */ -object MotionAggregator { - - private const val BLOCK_GRID = 8 - private const val BLOCK_ACTIVE_RATIO = 0.30f - private const val MAX_REGIONS = 3 - - fun aggregate( - diff: IntArray, - width: Int, - height: Int, - ): List { - val bw = width / BLOCK_GRID - val bh = height / BLOCK_GRID - if (bw == 0 || bh == 0) return emptyList() - - val active = BooleanArray(BLOCK_GRID * BLOCK_GRID) - for (by in 0 until BLOCK_GRID) { - for (bx in 0 until BLOCK_GRID) { - val blockW = if (bx == BLOCK_GRID - 1) width - bx * bw else bw - val blockH = if (by == BLOCK_GRID - 1) height - by * bh else bh - var motion = 0 - for (y in by * bh until by * bh + blockH) { - var idx = y * width + bx * bw - for (x in 0 until blockW) { - motion += diff[idx + x] - } - idx += width - } - active[by * BLOCK_GRID + bx] = motion > blockW * blockH * BLOCK_ACTIVE_RATIO - } - } - - // 连通块聚合(4 邻域) - val regions = ArrayList(MAX_REGIONS) - val visited = BooleanArray(active.size) - for (i in active.indices) { - if (!active[i] || visited[i]) continue - var minX = BLOCK_GRID - var minY = BLOCK_GRID - var maxX = -1 - var maxY = -1 - val stack = ArrayDeque() - stack.add(i) - visited[i] = true - while (stack.isNotEmpty()) { - val cur = stack.removeLast() - val bx = cur % BLOCK_GRID - val by = cur / BLOCK_GRID - minX = minOf(minX, bx) - maxX = maxOf(maxX, bx) - minY = minOf(minY, by) - maxY = maxOf(maxY, by) - for (nb in neighbors(cur)) { - if (active[nb] && !visited[nb]) { - visited[nb] = true - stack.add(nb) - } - } - } - if (maxX - minX > 3 || maxY - minY > 3) continue // 全屏噪声过滤 - regions += MotionRegion( - left = minX * bw / width.toFloat(), - top = minY * bh / height.toFloat(), - right = ((maxX + 1) * bw).coerceAtMost(width) / width.toFloat(), - bottom = ((maxY + 1) * bh).coerceAtMost(height) / height.toFloat(), - ) - if (regions.size >= MAX_REGIONS) break - } - return regions - } - - private fun neighbors(i: Int): List { - val bx = i % BLOCK_GRID - val by = i / BLOCK_GRID - val list = ArrayList(4) - if (bx > 0) list += i - 1 - if (bx < BLOCK_GRID - 1) list += i + 1 - if (by > 0) list += i - BLOCK_GRID - if (by < BLOCK_GRID - 1) list += i + BLOCK_GRID - return list - } - - /** 检测框中心是否落在运动区域内(用于置信度提升判定) */ - fun centerInRegion(box: DetectionResult, region: MotionRegion): Boolean = - box.centerX in region.left..region.right && box.centerY in region.top..region.bottom - - /** 帧差掩码生成(纯 JVM):|g - prev| > threshold → 1 */ - fun diffMask(gray: IntArray, prev: IntArray, threshold: Int = 25): IntArray { - val diff = IntArray(gray.size) - for (i in gray.indices) { - diff[i] = if (abs(gray[i] - prev[i]) > threshold) 1 else 0 - } - return diff - } -} diff --git a/app/src/main/java/com/example/observer/detection/MotionRegion.kt b/app/src/main/java/com/example/observer/detection/MotionRegion.kt deleted file mode 100644 index cb49cf9..0000000 --- a/app/src/main/java/com/example/observer/detection/MotionRegion.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.example.observer.detection - -/** 运动区域(归一化坐标) */ -data class MotionRegion( - val left: Float, - val top: Float, - val right: Float, - val bottom: Float, -) { - val centerX: Float get() = (left + right) / 2f - val centerY: Float get() = (top + bottom) / 2f -} diff --git a/app/src/main/java/com/example/observer/detection/Nms.kt b/app/src/main/java/com/example/observer/detection/Nms.kt deleted file mode 100644 index 8aca430..0000000 --- a/app/src/main/java/com/example/observer/detection/Nms.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.example.observer.detection - -fun iou(a: DetectionResult, b: DetectionResult): Float { - val x0 = maxOf(a.left, b.left) - val y0 = maxOf(a.top, b.top) - val x1 = minOf(a.right, b.right) - val y1 = minOf(a.bottom, b.bottom) - if (x1 <= x0 || y1 <= y0) return 0f - val inter = (x1 - x0) * (y1 - y0) - val union = a.width * a.height + b.width * b.height - inter - return if (union <= 0f) 0f else inter / union -} - -fun nms(boxes: List, iouThreshold: Float): List { - val sorted = boxes.sortedByDescending { it.score } - val kept = mutableListOf() - for (b in sorted) { - if (kept.none { iou(b, it) > iouThreshold }) kept += b - } - return kept -} diff --git a/app/src/main/java/com/example/observer/detection/TFLiteDetector.kt b/app/src/main/java/com/example/observer/detection/TFLiteDetector.kt deleted file mode 100644 index 81e2f4c..0000000 --- a/app/src/main/java/com/example/observer/detection/TFLiteDetector.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.example.observer.detection - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.Canvas -import android.graphics.Matrix -import org.tensorflow.lite.Interpreter -import org.tensorflow.lite.gpu.GpuDelegate -import java.nio.ByteBuffer -import java.nio.ByteOrder - -/** - * YOLOv8n 端侧推理实现。 - * 模型输出布局(ultralytics tflite 导出):[1, 4 + nc, 8400], - * cx/cy/w/h 已归一化,类别得分已过 sigmoid;按列平铺:index = c * 8400 + anchor。 - */ -class TFLiteDetector private constructor( - private val interpreter: Interpreter, - private val labels: List, -) : Detector { - - @Volatile var confThreshold: Float = 0.40f - - @Volatile var habitatThreshold: Float = 0.35f - - private val scaledBitmap = Bitmap.createBitmap(INPUT_SIZE, INPUT_SIZE, Bitmap.Config.ARGB_8888) - private val scaleCanvas = Canvas(scaledBitmap) - private val scaleMatrix = Matrix() - private val pixels = IntArray(INPUT_SIZE * INPUT_SIZE) - - private val inputBuffer: ByteBuffer = - ByteBuffer.allocateDirect(1 * INPUT_SIZE * INPUT_SIZE * 3 * 4) - .order(ByteOrder.nativeOrder()) - - private val outputFloats = FloatArray((4 + labels.size) * NUM_ANCHORS) - - override fun detect(bitmap: Bitmap): List { - preprocess(bitmap) - interpreter.run(inputBuffer, outputFloats) - return postprocess() - } - - private fun preprocess(bitmap: Bitmap) { - // CENTER_CROP 缩放保持宽高比 - val scale = maxOf(INPUT_SIZE.toFloat() / bitmap.width, INPUT_SIZE.toFloat() / bitmap.height) - val dx = (INPUT_SIZE - bitmap.width * scale) / 2f - val dy = (INPUT_SIZE - bitmap.height * scale) / 2f - scaleMatrix.reset() - scaleMatrix.setScale(scale, scale) - scaleMatrix.postTranslate(dx, dy) - scaleCanvas.drawBitmap(bitmap, scaleMatrix, null) - - inputBuffer.rewind() - scaledBitmap.getPixels(pixels, 0, INPUT_SIZE, 0, 0, INPUT_SIZE, INPUT_SIZE) - for (p in pixels) { - inputBuffer.putFloat(((p shr 16) and 0xFF) / 255f) - inputBuffer.putFloat(((p shr 8) and 0xFF) / 255f) - inputBuffer.putFloat((p and 0xFF) / 255f) - } - } - - private fun postprocess(): List { - val boxes = ArrayList(8) - for (a in 0 until NUM_ANCHORS) { - val cx = outputFloats[a] - val cy = outputFloats[NUM_ANCHORS + a] - val w = outputFloats[2 * NUM_ANCHORS + a] - val h = outputFloats[3 * NUM_ANCHORS + a] - var bestCls = 0 - var bestScore = 0f - for (c in 0 until labels.size) { - val s = outputFloats[(4 + c) * NUM_ANCHORS + a] - if (s > bestScore) { - bestScore = s - bestCls = c - } - } - val label = labels.getOrElse(bestCls) { "unknown" } - // cover 用独立阈值;动物类保留低分池,供运动检测提升 - val threshold = if (label == DetectionResult.HABITAT_LABEL) habitatThreshold else MIN_SCORE - if (bestScore < threshold) continue - boxes += DetectionResult( - label = label, - score = bestScore, - left = (cx - w / 2f).coerceIn(0f, 1f), - top = (cy - h / 2f).coerceIn(0f, 1f), - right = (cx + w / 2f).coerceIn(0f, 1f), - bottom = (cy + h / 2f).coerceIn(0f, 1f), - ) - } - return nms(boxes, IOU_THRESHOLD).take(MAX_DETECTIONS) - } - - companion object { - const val INPUT_SIZE = 320 - - /** 动物类最低保留分数:低于此分不输出(低分候选由运动检测提升显示) */ - const val MIN_SCORE = 0.20f - private const val NUM_ANCHORS = 8400 - private const val IOU_THRESHOLD = 0.45f - private const val MAX_DETECTIONS = 20 - private const val MODEL_ASSET = "model.tflite" - private const val LABELS_ASSET = "labels.txt" - - /** 模型缺失或加载失败返回 null(App 降级为仅预览) */ - fun create(context: Context): TFLiteDetector? { - return try { - val options = Interpreter.Options().apply { - setNumThreads(4) - try { - addDelegate(GpuDelegate()) - } catch (_: Throwable) { - // GPU 不可用,回退 CPU - } - } - val interpreter = Interpreter(loadModelFile(context), options) - val labels = context.assets.open(LABELS_ASSET).bufferedReader().readLines() - TFLiteDetector(interpreter, labels) - } catch (e: Exception) { - null - } - } - - private fun loadModelFile(context: Context): ByteBuffer { - val bytes = context.assets.open(MODEL_ASSET).use { it.readBytes() } - return ByteBuffer.allocateDirect(bytes.size).order(ByteOrder.nativeOrder()) - .apply { put(bytes); rewind() } - } - } -} diff --git a/app/src/main/java/com/example/observer/distance/DistanceEstimator.kt b/app/src/main/java/com/example/observer/distance/DistanceEstimator.kt deleted file mode 100644 index 81a178d..0000000 --- a/app/src/main/java/com/example/observer/distance/DistanceEstimator.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.example.observer.distance - -import android.content.Context -import android.hardware.camera2.CameraCharacteristics -import android.hardware.camera2.CameraManager -import kotlin.math.roundToInt -import kotlin.math.tan - -/** - * 单目距离估计(针孔模型):距离 = 焦距px × 参考体型 / 框高px。 - * 误差预期 ±30%(5~50m);生境区域按植被高度估算,均仅供参考。 - */ -class DistanceEstimator(context: Context) { - - private val cameraManager = - context.getSystemService(Context.CAMERA_SERVICE) as CameraManager - - private val focalPxCache = HashMap() - - // 参考体型(米) - private val speciesSizeM = mapOf( - "pheasant" to 0.45f, // 身高 - "cover" to 0.50f, // 植被高度(误差大) - ) - - fun estimate( - label: String, - boxHeightNorm: Float, - visibleHeightPx: Int, - cameraId: String?, - ): Float? { - val realH = speciesSizeM[label] ?: return null - val boxH = boxHeightNorm * visibleHeightPx - if (boxH < 8f) return null // 过小目标不估算 - val focalPx = focalPx(visibleHeightPx, cameraId) - if (focalPx <= 0f) return null - return (focalPx * realH / boxH).roundToInt().toFloat() - } - - /** focal_px = focal_mm × (imageHeightPx / sensorHeightMm);内参缺失时用视场角推算 */ - private fun focalPx(imageHeightPx: Int, cameraId: String?): Float { - val key = "$cameraId:$imageHeightPx" - focalPxCache[key]?.let { return it } - val id = cameraId ?: return -1f - val value = try { - val c = cameraManager.getCameraCharacteristics(id) - val focalMm = c.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS) - ?.firstOrNull() - val sensor = c.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE) - if (focalMm != null && sensor != null) { - focalMm * imageHeightPx / sensor.height - } else { - fovFallback(imageHeightPx, c) - } - } catch (e: Exception) { - -1f - } - focalPxCache[key] = value - return value - } - - /** focal_px = imageHeightPx / (2·tan(fovV/2)) */ - private fun fovFallback(imageHeightPx: Int, c: CameraCharacteristics): Float { - val fovV = c.get(CameraCharacteristics.LENS_INFO_AVAILABLE_VERTICAL_VIEW_ANGLES) - ?.firstOrNull() ?: return -1f - return (imageHeightPx / (2.0 * tan(Math.toRadians(fovV / 2.0)))).toFloat() - } -} diff --git a/app/src/main/java/com/example/observer/overlay/DetectionOverlay.kt b/app/src/main/java/com/example/observer/overlay/DetectionOverlay.kt deleted file mode 100644 index b710d5f..0000000 --- a/app/src/main/java/com/example/observer/overlay/DetectionOverlay.kt +++ /dev/null @@ -1,79 +0,0 @@ -package com.example.observer.overlay - -import android.graphics.Paint -import androidx.compose.foundation.Canvas -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.PathEffect -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.unit.dp -import com.example.observer.detection.CoordinateMapper -import com.example.observer.detection.DetectionResult -import kotlin.math.roundToInt - -private val speciesColors = mapOf( - "pheasant" to Color(0xFFE53935), - "cover" to Color(0xFFFDD835), -) - -private val speciesLabels = mapOf( - "pheasant" to "野鸡", - "cover" to "疑似区域", -) - -@Composable -fun DetectionOverlay( - results: List, - rotation: Int, - imageW: Int, - imageH: Int, - modifier: Modifier = Modifier, -) { - Canvas(modifier = modifier) { - val viewW = size.width.toInt() - val viewH = size.height.toInt() - val textPaint = Paint().apply { - isAntiAlias = true - textSize = 30f - } - results.forEach { r -> - val rect = CoordinateMapper.mapToView( - r.left, r.top, r.right, r.bottom, - rotation, imageW, imageH, viewW, viewH, - ) - val color = speciesColors[r.label] ?: Color.White - val stroke = Stroke( - width = 6.dp.toPx(), - pathEffect = if (r.isHabitat) { - PathEffect.dashPathEffect(floatArrayOf(20.dp.toPx(), 12.dp.toPx())) - } else { - null - }, - ) - drawRect( - color = color, - topLeft = Offset(rect.left, rect.top), - size = Size(rect.width, rect.height), - style = stroke, - ) - textPaint.color = color.toArgb() - val text = buildString { - append(speciesLabels[r.label] ?: r.label) - append(" ${(r.score * 100).toInt()}%") - r.distanceM?.let { append(" · 约${it.roundToInt()}m") } - } - val labelTop = (rect.top - textPaint.textSize - 6).coerceAtLeast(0f) - drawContext.canvas.nativeCanvas.drawText( - text, - rect.left.coerceAtLeast(0f) + 4f, - labelTop + textPaint.textSize, - textPaint, - ) - } - } -} diff --git a/app/src/main/java/com/example/observer/reminder/Reminder.kt b/app/src/main/java/com/example/observer/reminder/Reminder.kt deleted file mode 100644 index 47cdf3a..0000000 --- a/app/src/main/java/com/example/observer/reminder/Reminder.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.example.observer.reminder - -import android.content.Context -import android.media.AudioManager -import android.media.ToneGenerator -import android.os.Build -import android.os.SystemClock -import android.os.VibrationEffect -import android.os.Vibrator - -class Reminder(context: Context) { - - @Volatile var vibrateEnabled: Boolean = true - - @Volatile var soundEnabled: Boolean = true - - @Volatile var habitatAlertEnabled: Boolean = true - - private val vibrator: Vibrator? = - context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator - - private val toneGenerator: ToneGenerator? = try { - ToneGenerator(AudioManager.STREAM_NOTIFICATION, 60) - } catch (e: RuntimeException) { - null - } - - private var lastAlertLabel: String? = null - private var lastAlertAt = 0L - - /** 同类目标 10s 内只提醒一次;生境区域(cover)提醒方式与动物检测区分 */ - fun onDetected(label: String) { - val now = SystemClock.elapsedRealtime() - if (label == lastAlertLabel && now - lastAlertAt < 10_000) return - lastAlertAt = now - lastAlertLabel = label - - val isHabitat = label == "cover" - if (isHabitat && !habitatAlertEnabled) return - - if (vibrateEnabled) vibrate(isHabitat) - if (soundEnabled) playTone(isHabitat) - } - - private fun vibrate(isHabitat: Boolean) { - val v = vibrator ?: return - if (Build.VERSION.SDK_INT >= 26) { - val effect = if (isHabitat) { - VibrationEffect.createWaveform(longArrayOf(0, 150, 80, 150), -1) - } else { - VibrationEffect.createOneShot(200, VibrationEffect.DEFAULT_AMPLITUDE) - } - v.vibrate(effect) - } else { - @Suppress("DEPRECATION") - v.vibrate(if (isHabitat) longArrayOf(0, 150, 80, 150) else longArrayOf(0, 200), -1) - } - } - - private fun playTone(isHabitat: Boolean) { - val tone = toneGenerator ?: return - if (isHabitat) { - // 双短音:生境预警 - tone.startTone(ToneGenerator.TONE_PROP_BEEP2, 120) - tone.startTone(ToneGenerator.TONE_PROP_BEEP2, 120) - } else { - tone.startTone(ToneGenerator.TONE_PROP_BEEP2, 200) - } - } - - fun release() { - toneGenerator?.release() - } -} diff --git a/app/src/main/java/com/example/observer/ui/ObserverApp.kt b/app/src/main/java/com/example/observer/ui/ObserverApp.kt deleted file mode 100644 index 54bbc2b..0000000 --- a/app/src/main/java/com/example/observer/ui/ObserverApp.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.example.observer.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import com.example.observer.ui.camera.CameraScreen -import com.example.observer.ui.settings.SettingsScreen -import com.example.observer.ui.theme.ObserverTheme - -@Composable -fun ObserverApp() { - ObserverTheme { - var showSettings by rememberSaveable { mutableStateOf(false) } - if (showSettings) { - SettingsScreen(onBack = { showSettings = false }) - } else { - CameraScreen(onOpenSettings = { showSettings = true }) - } - } -} diff --git a/app/src/main/java/com/example/observer/ui/camera/CameraScreen.kt b/app/src/main/java/com/example/observer/ui/camera/CameraScreen.kt deleted file mode 100644 index 74102ec..0000000 --- a/app/src/main/java/com/example/observer/ui/camera/CameraScreen.kt +++ /dev/null @@ -1,244 +0,0 @@ -package com.example.observer.ui.camera - -import android.Manifest -import android.content.pm.PackageManager -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.camera.view.PreviewView -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel -import com.example.observer.ObserverApp -import com.example.observer.camera.CameraController -import com.example.observer.camera.FrameAnalyzer -import com.example.observer.camera.MotionDetector -import com.example.observer.data.AppSettings -import com.example.observer.data.DetectMode -import com.example.observer.overlay.DetectionOverlay -import kotlinx.coroutines.launch -import java.util.concurrent.Executors - -@Composable -fun CameraScreen(onOpenSettings: () -> Unit) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - val container = (context.applicationContext as ObserverApp).container - val viewModel: CameraViewModel = viewModel( - factory = remember { CameraViewModelFactory(container) }, - ) - val state by viewModel.state.collectAsStateWithLifecycle() - val settings by container.settingsRepository.settings.collectAsStateWithLifecycle( - initialValue = AppSettings(), - ) - val scope = rememberCoroutineScope() - - val executor = remember { Executors.newSingleThreadExecutor() } - val previewView = remember { - PreviewView(context).apply { - scaleType = PreviewView.ScaleType.FIT_CENTER - implementationMode = PreviewView.ImplementationMode.PERFORMANCE - } - } - val cameraController = remember { CameraController(lifecycleOwner, executor) } - val motionDetector = remember { MotionDetector() } - val analyzer = remember(container.detector) { - container.detector?.let { d -> - FrameAnalyzer(d, motionDetector) { results, rotation, w, h, motion -> - viewModel.onFramesAnalyzed(results, rotation, w, h, motion) - } - } - } - val interval by viewModel.analyzerIntervalMs.collectAsState() - - var hasCameraPermission by remember { - mutableStateOf( - ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == - PackageManager.PERMISSION_GRANTED, - ) - } - val permissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { granted -> hasCameraPermission = granted } - var retryKey by remember { mutableIntStateOf(0) } - var initFailed by remember { mutableStateOf(false) } - - DisposableEffect(Unit) { - cameraController.onCameraIdChanged = { viewModel.onCameraIdChanged(it) } - onDispose { executor.shutdown() } - } - LaunchedEffect(interval) { analyzer?.intervalMs = interval } - LaunchedEffect(retryKey) { - cameraController.onInitError = { initFailed = true } - if (hasCameraPermission) cameraController.start(previewView, analyzer) - } - LaunchedEffect(Unit) { - if (!hasCameraPermission) permissionLauncher.launch(Manifest.permission.CAMERA) - } - - Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { - if (hasCameraPermission) { - AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize()) - - if (state.modelReady) { - DetectionOverlay( - results = state.results, - rotation = state.rotation, - imageW = state.imageWidthPx, - imageH = state.imageHeightPx, - modifier = Modifier.fillMaxSize(), - ) - } else { - Box( - modifier = Modifier.fillMaxSize().padding(24.dp), - contentAlignment = Alignment.Center, - ) { - Text( - "模型未加载:请将训练好的 model.tflite 放入 app/src/main/assets 后重新构建", - color = Color.White, - textAlign = TextAlign.Center, - ) - } - } - - if (initFailed) { - Column( - modifier = Modifier - .align(Alignment.Center) - .background(Color(0x99000000), RoundedCornerShape(12.dp)) - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text("相机初始化失败", color = Color.White) - Spacer(Modifier.height(8.dp)) - TextButton(onClick = { - initFailed = false - retryKey++ - }) { Text("重试") } - } - } - - CameraTopBar( - detectMode = settings.detectMode, - onCycleMode = { - val next = DetectMode.entries[ - (settings.detectMode.ordinal + 1) % DetectMode.entries.size - ] - scope.launch { container.settingsRepository.setDetectMode(next) } - }, - onSwitchCamera = { cameraController.switchCamera(previewView, analyzer) }, - modifier = Modifier.align(Alignment.TopCenter), - ) - CameraBottomBar( - vibrateEnabled = settings.vibrateEnabled, - onToggleVibrate = { - scope.launch { - container.settingsRepository.setVibrateEnabled(!settings.vibrateEnabled) - } - }, - onOpenSettings = onOpenSettings, - modifier = Modifier.align(Alignment.BottomCenter), - ) - } else { - PermissionGuide( - onRequest = { permissionLauncher.launch(Manifest.permission.CAMERA) }, - modifier = Modifier.align(Alignment.Center), - ) - } - } -} - -@Composable -private fun CameraTopBar( - detectMode: DetectMode, - onCycleMode: () -> Unit, - onSwitchCamera: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier - .fillMaxWidth() - .background(Color(0x99000000)) - .padding(horizontal = 12.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - TextButton(onClick = onCycleMode) { - Text("频率:${detectMode.label}", color = Color.White) - } - TextButton(onClick = onSwitchCamera) { - Text("切换摄像头", color = Color.White) - } - } -} - -@Composable -private fun CameraBottomBar( - vibrateEnabled: Boolean, - onToggleVibrate: () -> Unit, - onOpenSettings: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier - .fillMaxWidth() - .background(Color(0x99000000)) - .padding(horizontal = 12.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - TextButton(onClick = onToggleVibrate) { - Text(if (vibrateEnabled) "提醒:开" else "提醒:关", color = Color.White) - } - TextButton(onClick = onOpenSettings) { - Text("设置", color = Color.White) - } - } -} - -@Composable -private fun PermissionGuide(onRequest: () -> Unit, modifier: Modifier = Modifier) { - Column( - modifier = modifier.padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - "需要相机权限才能进行实时识别", - color = Color.White, - textAlign = TextAlign.Center, - ) - Spacer(Modifier.height(16.dp)) - Button(onClick = onRequest) { Text("授权相机") } - } -} diff --git a/app/src/main/java/com/example/observer/ui/camera/CameraViewModel.kt b/app/src/main/java/com/example/observer/ui/camera/CameraViewModel.kt deleted file mode 100644 index d9e906b..0000000 --- a/app/src/main/java/com/example/observer/ui/camera/CameraViewModel.kt +++ /dev/null @@ -1,189 +0,0 @@ -package com.example.observer.ui.camera - -import android.os.SystemClock -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import com.example.observer.AppContainer -import com.example.observer.data.DetectMode -import com.example.observer.data.SettingsRepository -import com.example.observer.detection.DetectionResult -import com.example.observer.detection.MotionAggregator -import com.example.observer.detection.MotionRegion -import com.example.observer.detection.TFLiteDetector -import com.example.observer.distance.DistanceEstimator -import com.example.observer.reminder.Reminder -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import java.util.LinkedHashMap - -data class CameraUiState( - val modelReady: Boolean = false, - val results: List = emptyList(), - val rotation: Int = 90, - val imageWidthPx: Int = 0, - val imageHeightPx: Int = 0, -) - -class CameraViewModel( - private val detector: TFLiteDetector?, - private val distanceEstimator: DistanceEstimator, - private val reminder: Reminder, - settingsRepository: SettingsRepository, -) : ViewModel() { - - private val _state = MutableStateFlow(CameraUiState(modelReady = detector != null)) - val state: StateFlow = _state.asStateFlow() - - private val _analyzerIntervalMs = MutableStateFlow(DetectMode.CONTINUOUS.intervalMs) - val analyzerIntervalMs: StateFlow = _analyzerIntervalMs.asStateFlow() - - @Volatile - private var showDistance = true - - @Volatile - private var confThreshold = 0.40f - - @Volatile - private var cameraId: String? = null - - private val tracks = LinkedHashMap() - - init { - viewModelScope.launch { - settingsRepository.settings.collect { s -> - detector?.confThreshold = s.confThreshold - detector?.habitatThreshold = s.habitatThreshold - reminder.vibrateEnabled = s.vibrateEnabled - reminder.soundEnabled = s.soundEnabled - reminder.habitatAlertEnabled = s.habitatAlertEnabled - showDistance = s.showDistance - confThreshold = s.confThreshold - _analyzerIntervalMs.value = s.detectMode.intervalMs - } - } - } - - fun onCameraIdChanged(id: String) { - cameraId = id - } - - /** 帧分析回调(分析线程调用,需线程安全) */ - fun onFramesAnalyzed( - results: List, - rotation: Int, - imageWidthPx: Int, - imageHeightPx: Int, - motionRegions: List, - ) { - val now = SystemClock.elapsedRealtime() - val byKey = HashMap(results.size) - results.forEach { byKey[keyOf(it)] = it } - - synchronized(this) { - // 过期目标清除(消失 2s 后移除) - val expired = tracks.entries - .filter { it.key !in byKey && now - it.value.lastSeenMs > 2000 } - .map { it.key } - expired.forEach { tracks.remove(it) } - - byKey.forEach { (k, r) -> - val t = tracks[k] - tracks[k] = if (t == null) Track(now, now, r) else Track(t.firstSeenMs, now, r) - } - if (tracks.size > MAX_TRACKS) { - val oldest = tracks.entries.sortedBy { it.value.lastSeenMs }.take(tracks.size - MAX_TRACKS) - oldest.forEach { tracks.remove(it.key) } - } - - // 目标停留 ≥ 0.5s 才展示;消失 2s 内不闪断 - val visible = tracks.values - .filter { now - it.firstSeenMs >= 500 && now - it.lastSeenMs <= 2000 } - .filter { shouldShow(it.result, motionRegions) } - .map { t -> - val r = t.result - val boosted = r.score < confThreshold && - motionRegions.any { MotionAggregator.centerInRegion(r, it) } - val distance = if (showDistance) { - // 模型 CENTER_CROP 到方形输入, 归一化框高对应原图较短边(最大内接正方形) - distanceEstimator.estimate( - r.label, r.height, minOf(imageWidthPx, imageHeightPx), cameraId, - ) - } else { - null - } - r.copy( - score = if (boosted) minOf(r.score + MOTION_BOOST, 1f) else r.score, - distanceM = distance, - ) - } - - // 提醒:刚变为可见的目标(防重复由 Reminder 按类别 10s 控制) - tracks.values.forEach { t -> - if (!shouldShow(t.result, motionRegions)) return@forEach - val age = now - t.firstSeenMs - if (age in 500..2100 && now - t.lastSeenMs <= 300) { - reminder.onDetected(t.result.label) - } - } - - _state.update { - it.copy( - results = visible, - rotation = rotation, - imageWidthPx = imageWidthPx, - imageHeightPx = imageHeightPx, - ) - } - } - } - - /** - * 显示判定:cover 已按生境阈值过滤;动物分低于阈值时, - * 仅当与运动区域重叠(低分候选)才提升显示。 - */ - private fun shouldShow(r: DetectionResult, motionRegions: List): Boolean { - if (r.isHabitat) return true - if (r.score >= confThreshold) return true - if (r.score < TFLiteDetector.MIN_SCORE) return false - return motionRegions.any { MotionAggregator.centerInRegion(r, it) } - } - - override fun onCleared() { - reminder.release() - } - - private fun keyOf(r: DetectionResult): String = - "${r.label}:${(r.centerX * 10).toInt()}:${(r.centerY * 10).toInt()}" - - private data class Track( - val firstSeenMs: Long, - val lastSeenMs: Long, - val result: DetectionResult, - ) - - companion object { - private const val MAX_TRACKS = 30 - - /** 低分目标与运动区域重叠时的置信度提升量 */ - private const val MOTION_BOOST = 0.15f - } -} - -class CameraViewModelFactory(private val container: AppContainer) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(CameraViewModel::class.java)) { - return CameraViewModel( - detector = container.detector as? TFLiteDetector, - distanceEstimator = container.distanceEstimator, - reminder = container.reminder, - settingsRepository = container.settingsRepository, - ) as T - } - throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") - } -} diff --git a/app/src/main/java/com/example/observer/ui/settings/SettingsScreen.kt b/app/src/main/java/com/example/observer/ui/settings/SettingsScreen.kt deleted file mode 100644 index 8235592..0000000 --- a/app/src/main/java/com/example/observer/ui/settings/SettingsScreen.kt +++ /dev/null @@ -1,109 +0,0 @@ -package com.example.observer.ui.settings - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.example.observer.ObserverApp -import com.example.observer.data.AppSettings -import com.example.observer.data.DetectMode -import kotlinx.coroutines.launch - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SettingsScreen(onBack: () -> Unit) { - val context = LocalContext.current - val repository = (context.applicationContext as ObserverApp).container.settingsRepository - val settings by repository.settings.collectAsStateWithLifecycle(initialValue = AppSettings()) - val scope = rememberCoroutineScope() - - Scaffold( - topBar = { - TopAppBar( - title = { Text("设置") }, - navigationIcon = { - TextButton(onClick = onBack) { Text("返回") } - }, - ) - }, - ) { padding -> - Column( - modifier = Modifier - .padding(padding) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text("动物识别阈值:${"%.2f".format(settings.confThreshold)}") - Slider( - value = settings.confThreshold, - onValueChange = { scope.launch { repository.setConfThreshold(it) } }, - valueRange = 0.2f..0.7f, - ) - - Text("生境区域阈值:${"%.2f".format(settings.habitatThreshold)}") - Slider( - value = settings.habitatThreshold, - onValueChange = { scope.launch { repository.setHabitatThreshold(it) } }, - valueRange = 0.2f..0.7f, - ) - - Text("检测频率") - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - DetectMode.entries.forEach { mode -> - FilterChip( - selected = settings.detectMode == mode, - onClick = { scope.launch { repository.setDetectMode(mode) } }, - label = { Text(mode.label) }, - ) - } - } - - SwitchRow("震动提醒", settings.vibrateEnabled) { - scope.launch { repository.setVibrateEnabled(it) } - } - SwitchRow("提示音", settings.soundEnabled) { - scope.launch { repository.setSoundEnabled(it) } - } - SwitchRow("距离标注", settings.showDistance) { - scope.launch { repository.setShowDistance(it) } - } - SwitchRow("生境区域预警", settings.habitatAlertEnabled) { - scope.launch { repository.setHabitatAlertEnabled(it) } - } - } - } -} - -@Composable -private fun SwitchRow(title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text(title) - Switch(checked = checked, onCheckedChange = onCheckedChange) - } -} diff --git a/app/src/main/java/com/example/observer/ui/theme/Theme.kt b/app/src/main/java/com/example/observer/ui/theme/Theme.kt deleted file mode 100644 index 97fc247..0000000 --- a/app/src/main/java/com/example/observer/ui/theme/Theme.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.example.observer.ui.theme - -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color - -private val DarkColors = darkColorScheme( - primary = Color(0xFF66BB6A), - onPrimary = Color(0xFF00391F), - secondary = Color(0xFF80CBC4), - surface = Color(0xFF1C1C1C), - background = Color(0xFF101010), - onBackground = Color(0xFFE4E4E4), - onSurface = Color(0xFFE4E4E4), -) - -@Composable -fun ObserverTheme(content: @Composable () -> Unit) { - MaterialTheme(colorScheme = DarkColors, content = content) -} diff --git a/app/src/main/res/drawable/ic_launcher.xml b/app/src/main/res/drawable/ic_launcher.xml deleted file mode 100644 index 87f295d..0000000 --- a/app/src/main/res/drawable/ic_launcher.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml deleted file mode 100644 index 0b6f2d0..0000000 --- a/app/src/main/res/values/strings.xml +++ /dev/null @@ -1,3 +0,0 @@ - - 野视 - diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml deleted file mode 100644 index 2a12f06..0000000 --- a/app/src/main/res/values/themes.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - diff --git a/app/src/test/java/com/example/observer/detection/CoordinateMapperTest.kt b/app/src/test/java/com/example/observer/detection/CoordinateMapperTest.kt deleted file mode 100644 index 2773bf3..0000000 --- a/app/src/test/java/com/example/observer/detection/CoordinateMapperTest.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.example.observer.detection - -import org.junit.Assert.assertEquals -import org.junit.Test - -class CoordinateMapperTest { - - @Test - fun rotation90_center_mapsToViewCenter() { - // 横屏图像 1280x720,旋转 90 后竖屏显示 720x1280,视图 1080x1920(等比,无偏移) - val rect = CoordinateMapper.mapToView( - normLeft = 0.4f, normTop = 0.4f, normRight = 0.6f, normBottom = 0.6f, - rotation = 90, imageW = 1280, imageH = 720, - viewW = 1080, viewH = 1920, - ) - assertEquals(540f, rect.centerX, 1f) - assertEquals(960f, rect.centerY, 1f) - } - - @Test - fun rotation90_corner_appliesFitCenterOffset() { - // 视图 1080x2400,竖屏图像 720x1280,scale=1.5,纵向偏移 (2400-1920)/2=240 - val rect = CoordinateMapper.mapToView( - normLeft = 0f, normTop = 0f, normRight = 0.5f, normBottom = 0.5f, - rotation = 90, imageW = 1280, imageH = 720, - viewW = 1080, viewH = 2400, - ) - // 旋转后:x0=1-bottom=0.5, y0=left=0, x1=1-top=1, y1=right=0.5 - assertEquals(540f, rect.left, 1f) // 0.5 * 720 * 1.5 - assertEquals(240f, rect.top, 1f) // 0 * 1280 * 1.5 + 240 - assertEquals(1080f, rect.right, 1f) // 1 * 720 * 1.5 - assertEquals(1200f, rect.bottom, 1f) // 0.5 * 1280 * 1.5 + 240 - } - - @Test - fun rotation0_keepsCoordinates() { - val rect = CoordinateMapper.mapToView( - normLeft = 0.2f, normTop = 0.3f, normRight = 0.5f, normBottom = 0.7f, - rotation = 0, imageW = 1080, imageH = 1920, - viewW = 1080, viewH = 1920, - ) - assertEquals(216f, rect.left, 1f) - assertEquals(576f, rect.top, 1f) - assertEquals(540f, rect.right, 1f) - assertEquals(1344f, rect.bottom, 1f) - } - - @Test - fun rotation180_flipsBothAxes() { - val rect = CoordinateMapper.mapToView( - normLeft = 0.2f, normTop = 0.2f, normRight = 0.4f, normBottom = 0.4f, - rotation = 180, imageW = 1080, imageH = 1920, - viewW = 1080, viewH = 1920, - ) - // 翻转:x'=1-x, y'=1-y - assertEquals(648f, rect.left, 1f) // (1-0.4) * 1080 - assertEquals(1152f, rect.top, 1f) // (1-0.4) * 1920 - assertEquals(864f, rect.right, 1f) // (1-0.2) * 1080 - assertEquals(1536f, rect.bottom, 1f) // (1-0.2) * 1920 - } -} diff --git a/app/src/test/java/com/example/observer/detection/MotionAggregatorTest.kt b/app/src/test/java/com/example/observer/detection/MotionAggregatorTest.kt deleted file mode 100644 index c8f3427..0000000 --- a/app/src/test/java/com/example/observer/detection/MotionAggregatorTest.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.example.observer.detection - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class MotionAggregatorTest { - - // 96x64 图,8x8 块 → 每块 12x8 像素 - - @Test - fun noMotion_returnsEmpty() { - val diff = IntArray(96 * 64) - assertTrue(MotionAggregator.aggregate(diff, 96, 64).isEmpty()) - } - - @Test - fun singleBlockMotion_detectsRegion() { - val diff = IntArray(96 * 64) - // 块 (2,3):x 24..35, y 24..31,全部置 1 - for (y in 24 until 32) { - for (x in 24 until 36) diff[y * 96 + x] = 1 - } - val regions = MotionAggregator.aggregate(diff, 96, 64) - assertEquals(1, regions.size) - val r = regions[0] - assertTrue(r.left <= 24f / 96f && r.right >= 36f / 96f) - assertTrue(r.top <= 24f / 64f && r.bottom >= 32f / 64f) - } - - @Test - fun twoSeparateMotions_detectsTwoRegions() { - val diff = IntArray(96 * 64) - for (y in 0 until 8) for (x in 0 until 12) diff[y * 96 + x] = 1 - for (y in 48 until 64) for (x in 72 until 96) diff[y * 96 + x] = 1 - val regions = MotionAggregator.aggregate(diff, 96, 64) - assertEquals(2, regions.size) - } - - @Test - fun globalNoise_filteredOut() { - val diff = IntArray(96 * 64) - // 全屏散点噪声(随机块),但无连续连通域 - val rnd = java.util.Random(42) - for (i in diff.indices) if (rnd.nextFloat() < 0.1f) diff[i] = 1 - val regions = MotionAggregator.aggregate(diff, 96, 64) - assertTrue(regions.isEmpty()) - } - - @Test - fun centerInRegion_matches() { - val box = DetectionResult( - label = "hare", - score = 0.30f, - left = 0.2f, - top = 0.3f, - right = 0.4f, - bottom = 0.5f, - ) - val region = MotionRegion(0.1f, 0.2f, 0.5f, 0.6f) - assertTrue(MotionAggregator.centerInRegion(box, region)) - val outside = MotionRegion(0.6f, 0.7f, 0.9f, 0.9f) - assertFalse(MotionAggregator.centerInRegion(box, outside)) - } -} diff --git a/app/src/test/java/com/example/observer/detection/NmsTest.kt b/app/src/test/java/com/example/observer/detection/NmsTest.kt deleted file mode 100644 index f6ef17f..0000000 --- a/app/src/test/java/com/example/observer/detection/NmsTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.example.observer.detection - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class NmsTest { - - private fun box(l: Float, t: Float, r: Float, b: Float, score: Float, label: String = "x") = - DetectionResult(label, score, l, t, r, b) - - @Test - fun overlappingBoxes_keepHighestScore() { - val a = box(0.1f, 0.1f, 0.5f, 0.5f, 0.8f) - val b = box(0.12f, 0.12f, 0.52f, 0.52f, 0.6f) - val result = nms(listOf(a, b), 0.45f) - assertEquals(1, result.size) - assertEquals(0.8f, result[0].score, 1e-6f) - } - - @Test - fun separateBoxes_bothKept() { - val a = box(0.1f, 0.1f, 0.3f, 0.3f, 0.8f) - val b = box(0.7f, 0.7f, 0.9f, 0.9f, 0.6f) - val result = nms(listOf(a, b), 0.45f) - assertEquals(2, result.size) - } - - @Test - fun lowScoreBox_suppressedByHigherScore() { - val a = box(0.1f, 0.1f, 0.5f, 0.5f, 0.9f) - val b = box(0.1f, 0.1f, 0.5f, 0.5f, 0.5f) - val result = nms(listOf(b, a), 0.45f) - assertEquals(1, result.size) - assertTrue(result[0].score > 0.5f) - } - - @Test - fun iou_nonOverlapping_isZero() { - val a = box(0.0f, 0.0f, 0.2f, 0.2f, 1f) - val b = box(0.8f, 0.8f, 1f, 1f, 1f) - assertEquals(0f, iou(a, b), 1e-6f) - } - - @Test - fun iou_identicalBoxes_isOne() { - val a = box(0.1f, 0.1f, 0.5f, 0.5f, 1f) - assertEquals(1f, iou(a, a), 1e-6f) - } -} diff --git a/build.gradle.kts b/build.gradle.kts deleted file mode 100644 index 9deb573..0000000 --- a/build.gradle.kts +++ /dev/null @@ -1,5 +0,0 @@ -plugins { - alias(libs.plugins.android.application) apply false - alias(libs.plugins.kotlin.android) apply false - alias(libs.plugins.kotlin.compose) apply false -} diff --git a/docs/01-技术方案.md b/docs/01-技术方案.md deleted file mode 100644 index 5437a71..0000000 --- a/docs/01-技术方案.md +++ /dev/null @@ -1,320 +0,0 @@ -# Observer(野视)· 野生动物实时识别 Android App — 技术方案 - -| 项目 | 内容 | -| --- | --- | -| 文档版本 | v1.1 | -| 编写日期 | 2026-08-17 | -| 状态 | 初稿(v1.1:确认功能范围为"仅实时识别",移除拍照留存) | -| 适用产品 | 纯 Android 原生 App | - ---- - -## 1. 项目概述 - -### 1.1 项目背景 - -野生动物观察爱好者在户外需要一款便携工具:打开手机相机,即可从实时画面中识别野鸡,通过检测框与提醒辅助快速发现。 - -### 1.2 项目目标 - -- 端侧实时目标检测,**全程离线可用**,不依赖网络; -- 野鸡实时框选识别,展示类别、置信度与**大致距离**; -- 检测到目标时通过震动 / 声音提醒用户,辅助快速发现; -- 纯 Android 原生 App(Kotlin),兼容 Android 7.0+ 主流机型,中端机流畅运行; -- **不做拍照、记录、统计等留存功能**,专注实时识别这一核心体验。 - -### 1.3 名词术语 - -| 术语 | 说明 | -| --- | --- | -| ImageAnalysis | CameraX 的帧分析用例,用于逐帧回调图像数据 | -| TFLite | TensorFlow Lite,Google 端侧推理框架 | -| YOLO | You Only Look Once,单阶段目标检测算法 | -| NMS | Non-Maximum Suppression,非极大值抑制 | -| GPU Delegate | TFLite 的 GPU 加速委托,将算子下发 GPU 执行 | -| mAP | mean Average Precision,目标检测平均精度指标 | -| IoU | Intersection over Union,交并比 | - ---- - -## 2. 需求概述 - -### 2.1 目标用户 - -| 用户群 | 典型诉求 | -| --- | --- | -| 野生动物观察爱好者 | 户外实时识别画面中的野鸡,不惊扰、近距离观察 | -| 户外徒步 / 摄影人群 | 快速发现野鸡,辅助取景构图 | -| 自然教育、科普工作者 | 物种识别辅助教学 | - -### 2.2 核心场景 - -| 编号 | 场景 | 描述 | -| --- | --- | --- | -| S1 | 野外实时识别 | 徒步 / 观鸟时打开相机,实时框选画面中的野鸡,显示类别、置信度与大致距离 | -| S2 | 快速扫视寻找 | 移动取景快速扫视,检测到野鸡立即震动 / 声音提醒,无需停留操作 | - -### 2.3 功能需求摘要 - -实时识别(P0)、识别结果叠加展示(P0)、检测提醒(P1)、生境区域预警(P1)、设置(P1)、合规提示(P0)、低光增强(P2)。详见《项目功能文档》。 - -### 2.4 非功能需求摘要 - -| 指标 | 目标 | -| --- | --- | -| 识别延迟 | 中端机单帧 ≤ 80ms | -| 预览流畅度 | 连续模式 2~3 帧检测一次,预览不卡顿 | -| 耗电 | 连续使用 1 小时耗电 ≤ 15% | -| 兼容性 | Android 7.0+(minSdk 24),覆盖主流国产机与三星 | -| 稳定性 | 崩溃率 ≤ 0.5%,启动成功率 ≥ 99% | - ---- - -## 3. 总体架构 - -### 3.1 架构图 - -```mermaid -graph TD - subgraph UI层["UI 层 · Jetpack Compose"] - MainScreen["相机主界面
预览 + 检测叠加层"] - SettingsScreen["设置界面"] - end - - subgraph ViewModel层["ViewModel 层"] - CameraViewModel["CameraViewModel"] - SettingsViewModel["SettingsViewModel"] - end - - subgraph 领域层["领域层"] - Detector接口["Detector 接口"] - end - - subgraph 基础设施层["基础设施层"] - CameraX["CameraX
Preview / ImageAnalysis"] - TFLite["TFLite 推理引擎
YOLOv8n 模型 + GPU Delegate"] - DataStore["DataStore 设置存储"] - end - - UI层 --> ViewModel层 - ViewModel层 --> 领域层 - 领域层 --> 基础设施层 -``` - -### 3.2 分层说明 - -| 层 | 职责 | -| --- | --- | -| UI 层 | Compose 页面渲染、检测叠加层绘制、交互 | -| ViewModel 层 | 页面状态管理、识别结果分发、提醒触发 | -| 领域层 | 检测器抽象接口 | -| 基础设施层 | CameraX、TFLite、DataStore 等能力实现 | - -### 3.3 核心数据流(识别链路) - -```mermaid -flowchart LR - A["CameraX 相机帧"] --> B["ImageAnalysis 帧分析"] - B --> C["预处理
缩放 320×320 / 归一化 / 旋转校正"] - C --> D["TFLite 推理
YOLOv8n"] - D --> E["后处理
解码 / NMS / 阈值过滤"] - E --> F["叠加层渲染
边框 + 类别 + 置信度"] - F --> G["UI 展示"] - E --> H["结果提醒
震动 / 提示音"] -``` - -### 3.4 部署形态 - -- **本地优先**:识别全部离线完成,运行期无网络请求; -- **云端(V2 可选)**:仅用于模型版本更新下发。 - ---- - -## 4. 技术选型 - -| 类别 | 选型 | 理由 | -| --- | --- | --- | -| 开发语言 | Kotlin | Android 官方推荐,协程生态成熟 | -| UI 框架 | Jetpack Compose(Material 3) | 声明式 UI,开发效率高,叠加层绘制灵活 | -| 相机 | CameraX(Preview / ImageAnalysis) | Jetpack 官方库,生命周期安全,机型兼容性最佳 | -| 目标检测模型 | YOLOv8n(自定义 4 类训练) | 精度/速度均衡,端侧部署方案成熟 | -| 推理框架 | TensorFlow Lite 2.16+(GPU Delegate) | 官方支持 GPU 加速;备选 NCNN / MNN | -| 配置存储 | DataStore Preferences | 阈值、开关等设置 | -| 异步 | Kotlin Coroutines + Flow | 主线程安全,生命周期感知 | -| 构建 | Gradle(Kotlin DSL)+ AGP 8.x | 现代构建配置 | - ---- - -## 5. 核心功能技术方案 - -### 5.1 实时识别管线 - -- CameraX 组合:`Preview`(取景)+ `ImageAnalysis`(识别); -- `ImageAnalysis` 使用 `STRATEGY_KEEP_ONLY_LATEST` 背压策略,保证不积压帧; -- 输出格式使用 `OUTPUT_IMAGE_FORMAT_RGBA_8888`(CameraX 1.3+),免去 YUV→RGB 手动转换; -- **帧节流**:连续模式每 2~3 帧检测一次;标准模式 300ms 一次;省电模式每秒一次(依据设置); -- 推理在独立检测线程执行,单例互斥锁防止重叠推理;分析线程不阻塞主线程。 - -### 5.2 检测叠加层 - -- Compose Canvas 绘制检测框(矩形 + 类别标签 + 置信度),颜色按类别区分; -- 检测框内同时标注**大致距离**(如"约 25m"),随检测框实时更新; -- 距离标注与检测框同生共灭,展示逻辑一致(≥ 0.5s 出现、消失 2s 后移除); -- 坐标映射链路:模型归一化坐标 → 旋转校正(sensor rotation)→ 预览视图坐标(含 FIT_CENTER 裁剪偏移修正); -- 目标保持 ≥ 0.5s 才展示,避免单帧误检闪烁。 - -### 5.3 距离标注(单目估计) - -- 采用单目针孔模型:`距离 ≈ 焦距px × 物种参考体型 / 检测框像素高度`; -- 焦距 px 由相机内参换算(`LENS_INFO_AVAILABLE_FOCAL_LENGTHS` × `SENSOR_INFO_PHYSICAL_SIZE`),个别机型回退用视场角推算; -- 物种参考体型内置表(见 6.1),每类一个平均体型值; -- 展示为"约 X m";5~50m 范围误差预期 ≤ ±30%; -- 纯算术运算,无额外模型与算力开销;V2 可引入地面平面法(设备俯仰角 + 相机高度)提升精度。 - -### 5.4 检测提醒 - -- 检测到目标时触发震动 / 提示音,用户可开关; -- 防重复打扰:10s 内同类目标只提醒一次; -- 目标消失后 2s 内不闪断显示,避免频繁提醒。 - -### 5.5 低光增强(P2) - -- 简单直方图均衡 / 亮度增益提升低光帧可见度; -- V2 可引入夜视增强网络思路。 - ---- - -## 6. AI 模型方案(核心) - -### 6.1 目标类别定义 - -| 类别 ID | 英文标签 | 中文名 | 涵盖范围 | 参考体型(距离估计用) | -| --- | --- | --- | --- | --- | -| 0 | pheasant | 野鸡 | 环颈雉等雉类 | ≈ 0.45m(身高) | -| 1 | cover | 生境区域 | 草丛 / 灌木 / 水面等疑似生境(黄色虚线框预警,F08) | ≈ 0.5m(参考植被高度) | - -### 6.2 模型选型对比 - -| 模型 | 输入尺寸 | 中端机单帧耗时(GPU) | 精度 | 说明 | -| --- | --- | --- | --- | --- | -| **YOLOv8n(推荐)** | 320~640 | 约 30~80ms | 高 | 支持自定义类别训练,精度/速度平衡 | -| EfficientDet-Lite0/2 | 320 | 约 20~50ms | 中 | 仅 COCO 预置类别 | -| YOLOv5s | 640 | 约 80~150ms | 高 | 体积与耗电偏大 | -| SSD MobileNetV2 | 300 | 约 15~30ms | 低 | 小目标与远距离效果差 | - -**结论**:选用 **YOLOv8n**,使用预训练权重迁移学习自定义训练,导出 TFLite 端侧部署。 - -### 6.3 数据集方案 - -| 项 | 方案 | -| --- | --- | -| 数据量 | 1000~2000 张(首版可 500+ 起步,滚动补充) | -| 多样性 | 覆盖不同季节、晨昏/正午/逆光、远近距离、姿态、遮挡、背景(草丛/农田/林地/雪地) | -| 标注 | 人工标注(LabelImg 等工具),YOLO 格式(class, cx, cy, w, h) | -| 生境标注 | 人工标注可疑度最高的藏身点(cover 类,黄色框,约占画面 2%~15%),综合植被密度 / 地形 / 光线判断 | -| 数据增强 | Mosaic、MixUp、HSV 扰动、随机翻转、随机缩放裁剪 | -| 数据划分 | train 80% / val 10% / test 10% | -| 负样本 | 补充无目标场景图,控制误检 | - -### 6.4 训练方案 - -- 框架:ultralytics YOLOv8,预训练权重 `yolov8n.pt` 迁移学习; -- 超参:imgsz=640(训练)、epochs 100~200(早停)、batch 16~32(视 GPU 而定); -- 评估指标: - - mAP@0.5 ≥ **0.85**(达标线 0.80); - - mAP@0.5:0.95 ≥ **0.55**; - - 负样本误检率 ≤ **2%**; - - 远距离小目标(高度 ≤ 20px)召回率 ≥ **60%**。 - -### 6.5 模型转换与量化 - -- 导出命令:`yolo export model=best.pt format=tflite imgsz=320`; -- 量化策略: - - 优先 **fp16**(配合 GPU Delegate,精度损失小); - - 低端机 / CPU 场景使用 **int8** 量化(体积更小、CPU 更快); -- 输入尺寸:320×320(连续检测)/ 416×416(远距离模式,V2)。 - -### 6.6 推理优化 - -- GPU Delegate 优先,初始化失败自动回退 CPU(NNAPI 可选); -- 推理线程 4 线程,首次推理前执行预热(dummy run); -- 输入输出 ByteBuffer / Bitmap 复用,避免热路径频繁分配。 - -### 6.7 精度与速度目标 - -| 指标 | 目标 | -| --- | --- | -| 检测阈值(默认) | confidence ≥ 0.40,IoU-NMS = 0.45 | -| 单帧推理延迟 | ≤ 80ms(中端机,320 输入,GPU) | -| 模型体积 | fp16 ≤ 8MB,int8 ≤ 4MB | -| 识别类别数 | 2 类(野鸡 + 生境区域 cover) | -| 距离标注 | "约 X m",5~50m 误差 ≤ ±30%,计算开销可忽略 | - ---- - -## 7. 性能指标与优化 - -| 指标 | 目标值 | 主要优化手段 | -| --- | --- | --- | -| 启动到相机预览 | ≤ 1.5s | 启动即初始化 CameraProvider,懒加载非核心模块 | -| 单帧检测延迟 | ≤ 80ms | GPU Delegate、320 输入、线程复用 | -| 预览帧率 | ≥ 25fps | 帧节流、KEEP_ONLY_LATEST、避免主线程工作 | -| 内存峰值 | ≤ 200MB | Bitmap/ByteBuffer 复用,无大对象常驻 | -| 连续 1 小时耗电 | ≤ 15% | 检测帧节流、省电模式、后台自动释放相机 | -| App 体积 | ≤ 40MB | ABI 拆分、模型量化、R8 混淆 | - ---- - -## 8. 数据与隐私 - -- 无账号、无埋点、无广告 SDK,**不采集任何用户数据**; -- 不保存照片、不记录位置,运行期无网络请求; -- 模型与标签随 APK 打包,识别数据仅存在于内存中,随进程结束自动释放。 - ---- - -## 9. 安全与合规 - -- 产品定位为**野生动物观察、识别工具**,不提供任何猎捕、诱捕、伤害野生动物的功能或指导; -- 遵守《中华人民共和国野生动物保护法》《陆生野生动物保护实施条例》等法律法规; -- 野鸡(雉类)属"三有"保护动物,猎捕须依法取得许可,App 不鼓励、不协助非法猎捕; -- App 内置观察伦理提示:保持安全距离、不惊扰动物、遵守保护区管理规定(首次启动展示,功能文档 F07)。 - ---- - -## 10. 开发计划(里程碑) - -| 阶段 | 周期 | 交付内容 | -| --- | --- | --- | -| M0 准备 | 1 周 | 需求确认、数据集启动采集、Android 工程脚手架、模型基线 | -| M1 MVP | 3~4 周 | 相机预览 + 实时检测 + 叠加层 + 检测提醒 + 设置 + 合规提示 | -| M2 打磨发布 | 1~2 周 | 性能优化、真机矩阵测试、混淆加固、上架准备 | - -总计约 **5~7 周**(不含数据采集并行时间)。 - ---- - -## 11. 风险与应对 - -| 风险 | 影响 | 应对 | -| --- | --- | --- | -| 训练数据不足 / 类别相近 | 精度低、误检 | 持续滚动采集数据;增加难例挖掘;灰度发布迭代模型 | -| 小目标(远处动物) | 漏检 | 提高输入分辨率;帧节流换取算力;提示用户靠近/变焦;V2 引入 SAHI/tiling 或专用小目标模型 | -| 低光 / 夜间场景 | 漏检 | 低光增强预处理;V2 夜视模式 | -| 单目距离估计精度有限 | 距离显示不准 | 标注为"约"并明示误差预期;体型表持续校准;V2 地面平面法 + 水下折射修正 | -| 生境区域误报偏多 | 提醒频繁、体验差 | 独立低阈值 + 区分提醒方式 + 防重复机制;生境阈值可调;模型迭代降低误报 | -| 中低端机型性能不足 | 帧率低、发热 | 动态分辨率、降频检测、GPU 回退策略、省电模式 | -| CameraX 个别机型异常 | 黑屏/闪退 | 机型兼容测试矩阵、崩溃监控、失败回退(重试/默认配置) | -| 合规风险(被用于非法猎捕) | 法律风险 | 产品定位为观察工具、内置合规提示、不提供猎捕辅助功能 | - ---- - -## 12. 验收标准 - -1. 野鸡测试集 mAP@0.5 ≥ 0.85,负样本误检率 ≤ 2%; -2. 中端机(如骁龙 7 系)单帧检测 ≤ 80ms,预览流畅无卡顿; -3. 连续使用 1 小时耗电 ≤ 15%,无异常发热; -4. 主流机型(小米 / 华为 / OPPO / vivo / 三星,各 ≥ 2 台)启动成功率 ≥ 99%,崩溃率 ≤ 0.5%; -5. 飞行模式下实时识别全流程(预览、检测、提醒、设置)可用; -6. 检测框正确显示类别、置信度与距离;5~50m 范围距离误差 ≤ ±30%; -7. 通过应用商店合规审核(隐私政策、权限声明完整)。 diff --git a/docs/02-项目功能文档.md b/docs/02-项目功能文档.md deleted file mode 100644 index 8b9eaa7..0000000 --- a/docs/02-项目功能文档.md +++ /dev/null @@ -1,193 +0,0 @@ -# Observer(野视)· 项目功能文档 - -| 项目 | 内容 | -| --- | --- | -| 文档版本 | v1.1 | -| 编写日期 | 2026-08-17 | -| 状态 | 初稿(v1.1:确认功能范围为"仅实时识别",移除拍照留存) | -| 配套文档 | 《01-技术方案》《03-技术实现文档》 | - ---- - -## 1. 产品概述 - -### 1.1 产品定位 - -一款**纯 Android 原生**的野生动物实时识别 App。用户打开相机,即可从实时画面中识别野鸡,通过彩色检测框(含类别、置信度、距离标注)与震动 / 声音提醒辅助快速发现。**无拍照、无记录、无统计**,专注实时识别这一核心体验,**全程离线可用**。 - -### 1.2 目标用户 - -- 野生动物观察爱好者(观鸟 / 观兽); -- 户外徒步、摄影人群; -- 自然教育、科普工作者。 - -### 1.3 价值主张 - -- **即时发现**:打开相机即识别,检测到目标立即提醒,免去翻图鉴、猜物种; -- **零门槛**:不依赖网络,不依赖外设,一部手机即可; -- **纯净体验**:无留存功能、无账号、无广告,即开即用。 - ---- - -## 2. 功能架构(功能树) - -``` -Observer -├── 实时识别 -│ ├── 相机实时预览(F01) -│ ├── 目标实时检测(F02) -│ ├── 识别结果展示与距离标注(F03) -│ ├── 检测提醒(F04) -│ └── 低光增强(F06,P2) -├── 设置 -│ ├── 识别参数(F05) -│ └── 提醒设置(F04 子项) -└── 合规提示(F07) -``` - ---- - -## 3. 功能需求清单 - -优先级定义:**P0**=必须,**P1**=V1.0,**P2**=后续版本。 - -| 编号 | 功能 | 功能描述 | 优先级 | -| --- | --- | --- | --- | -| F01 | 相机实时预览 | 全屏取景,默认后置,支持自动对焦、双指缩放、点击对焦、前后摄切换 | P0 | -| F02 | 目标实时检测 | 对野鸡实时检测框选 | P0 | -| F03 | 识别结果展示与距离标注 | 检测框 + 类别名 + 置信度百分比 + 大致距离(约 X m);野鸡红色实线框,生境区域黄色虚线框 | P0 | -| F04 | 检测提醒 | 检测到目标时震动 / 提示音;支持开关与 10s 防重复 | P1 | -| F05 | 设置 | 置信度阈值(0.2~0.7)、检测频率(连续 / 标准 / 省电)、提醒开关、低光增强开关 | P1 | -| F06 | 低光增强 | 低光环境下自动亮度 / 对比度增强,提高识别率 | P2 | -| F07 | 合规提示 | 首次启动展示《观察伦理与法律提示》,需用户确认 | P0 | -| F08 | 生境区域预警 | 画面中未检测到目标时,识别草丛 / 灌木 / 水面等疑似生境区域,以黄色虚线框展示(含距离标注)并触发预警(独立阈值与提醒方式) | P1 | - ---- - -## 4. 核心业务流程 - -### 4.1 首次启动与授权 - -``` -启动 App → 合规提示页(F07,用户确认) - → 请求相机权限 - ├─ 授权 → 进入相机主界面 - └─ 拒绝 → 引导页说明用途,可重新授权 -``` - -### 4.2 实时识别 - -``` -相机主界面(默认后置) -→ 画面持续检测(连续 / 标准 / 省电频率) -→ 检测到目标:显示彩色检测框 + 类别 + 置信度 + 距离 - ├─ 触发提醒(震动 / 声音,10s 防重复) - └─ 目标停留 ≥ 0.5s 保持显示,消失后 2s 内不闪断 -→ 未检测到目标时:识别疑似生境区域(草丛 / 灌木 / 水面) - ├─ 黄色虚线框展示,标注"疑似区域"与距离 - └─ 触发预警(提醒方式与动物检测区分) -→ 用户可点击检测框查看识别详情(物种、置信度、时间) -``` - ---- - -## 5. 界面设计 - -### 5.1 界面总览 - -仅两个界面:**相机主界面**(默认全屏展示)+ **设置页**(右上角齿轮入口)。 - -### 5.2 相机主界面(核心界面) - -``` -┌─────────────────────────────────┐ -│ [低光] [频率] [前/后摄] │ ← 顶部工具 -│ 相机预览画面(全屏) │ -│ ┌─────────────────────────────┐ │ -│ │ ┌───────┐ │ │ -│ │ │ 野鸡 86% │ │ │ ← 检测框(按类别着色) -│ │ │ 约 25m │ │ │ -│ │ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ │ │ ← 疑似生境区域(黄色虚线,预警) -│ │ 疑似区域 · 约 20m │ │ -│ │ └───────┘ │ │ -│ └─────────────────────────────┘ │ -│ [提醒开关] [设置] │ ← 底部操作 -└─────────────────────────────────┘ -``` - -交互说明: - -- 顶部:检测频率切换(连续 / 标准 / 省电)、低光增强开关(P2)、前后摄像头切换; -- 中部:全屏预览,检测框 + 类别 + 置信度实时叠加; -- 底部:提醒开关(震动 / 声音)、设置入口; -- 支持双指缩放、点击对焦; -- 检测到目标时边框高亮 + 震动 / 声音提醒(10s 防重复); -- 检测框内显示类别、置信度与大致距离(约 X m),随目标实时更新; -- 未检测到目标时,疑似生境区域以黄色虚线框展示(含距离标注)并预警(提醒方式与动物检测区分)。 - -### 5.3 识别结果卡片(点击检测框弹出) - -| 元素 | 说明 | -| --- | --- | -| 物种名称 | 中文名 + 英文标签(如:野鸡 pheasant) | -| 置信度 | 百分比进度条 | -| 时间 | 检测时刻 | -| 距离 | 约 25m(单目估算,误差 ±30%) | - -仅展示信息,无保存操作。 - -疑似生境区域(黄色虚线框)点击后展示:区域类别、置信度、距离(约 X m,按参考植被高度估算,误差较大)。 - -### 5.4 设置页 - -| 分组 | 设置项 | 默认值 | -| --- | --- | --- | -| 识别 | 置信度阈值 | 0.40 | -| 识别 | 检测频率 | 连续 | -| 识别 | 低光增强 | 关(P2) | -| 识别 | 距离标注 | 开 | -| 识别 | 生境区域阈值 | 0.35 | -| 提醒 | 震动提醒 / 提示音 / 防重复时长 | 开 / 开 / 10s | -| 提醒 | 生境区域预警 | 开 | -| 关于 | 版本信息 / 隐私说明 | — | - ---- - -## 6. 权限设计 - -| 权限 | 用途 | 时机 | 备注 | -| --- | --- | --- | --- | -| CAMERA | 实时预览与识别 | 首次启动 | 必须 | - -原则:**最小权限、按需申请**。仅申请相机权限;拒绝后提供说明引导页(可跳转系统设置重新授权)。不申请定位、存储等任何其他权限。 - ---- - -## 7. 非功能需求 - -| 类别 | 要求 | -| --- | --- | -| 兼容性 | Android 7.0+(minSdk 24),竖屏为主,支持暗色模式 | -| 性能 | 见《技术方案》第 7 章(延迟 ≤ 80ms、启动 ≤ 1.5s、内存 ≤ 200MB) | -| 离线 | 飞行模式下实时识别全流程完整可用 | -| 稳定性 | 崩溃率 ≤ 0.5%,启动成功率 ≥ 99%,异常自动降级(GPU 回退 CPU) | -| 耗电 | 连续使用 1 小时 ≤ 15%;省电模式 ≤ 8% | -| 隐私 | 无广告 SDK、无埋点、不保存任何数据,识别数据仅存内存 | -| 无障碍 | 关键操作支持 TalkBack 描述;检测结果支持语音播报(V2) | - ---- - -## 8. 版本规划 - -| 版本 | 范围 | 说明 | -| --- | --- | --- | -| MVP / V1.0 | F01~F05、F07、F08 | 相机 + 实时识别 + 生境预警 + 提醒 + 设置,即完整核心体验 | -| V2.0 | +F06 及增强 | 低光增强、更多物种、夜视模式、模型远程更新、语音播报、距离精度增强(地面平面法) | - ---- - -## 9. 合规与安全说明 - -- App 定位为**野生动物观察、识别工具**,不提供猎捕、诱捕、伤害野生动物的功能或指导; -- 首次启动展示合规提示:遵守《中华人民共和国野生动物保护法》,野鸡属"三有"保护动物,猎捕须依法许可;观察时保持距离、不惊扰动物、遵守保护区规定; -- 上架需提供隐私政策,声明仅使用相机权限、不采集与存储任何用户数据。 diff --git a/docs/03-技术实现文档.md b/docs/03-技术实现文档.md deleted file mode 100644 index f81ceab..0000000 --- a/docs/03-技术实现文档.md +++ /dev/null @@ -1,512 +0,0 @@ -# Observer(野视)· 技术实现文档 - -| 项目 | 内容 | -| --- | --- | -| 文档版本 | v1.1 | -| 编写日期 | 2026-08-17 | -| 状态 | 初稿(v1.1:确认功能范围为"仅实时识别",移除拍照、记录、相册识别、定位相关实现) | -| 配套文档 | 《01-技术方案》《02-项目功能文档》 | - ---- - -## 1. 项目结构 - -``` -app/ -├── src/main/ -│ ├── java/com/example/observer/ -│ │ ├── MainActivity.kt // 单 Activity 入口 -│ │ ├── camera/ -│ │ │ ├── CameraController.kt // CameraX 生命周期绑定与用例组合 -│ │ │ └── FrameAnalyzer.kt // ImageAnalysis 帧分析器(节流 + 调度) -│ │ ├── detection/ -│ │ │ ├── Detector.kt // 检测器抽象接口 -│ │ │ ├── TFLiteDetector.kt // TFLite 实现(YOLOv8n) -│ │ │ ├── DetectionResult.kt // 检测结果数据类 -│ │ │ ├── Nms.kt // NMS 后处理 -│ │ │ └── CoordinateMapper.kt // 模型坐标 → 视图坐标映射 -│ │ ├── distance/ -│ │ │ └── DistanceEstimator.kt // 单目距离估计(针孔模型) -│ │ ├── overlay/ -│ │ │ └── DetectionOverlay.kt // Compose 叠加层(Canvas 绘制检测框) -│ │ ├── reminder/ -│ │ │ └── Reminder.kt // 震动 / 提示音提醒(含防重复) -│ │ ├── ui/ -│ │ │ ├── camera/ CameraScreen.kt / CameraViewModel.kt -│ │ │ └── settings/ SettingsScreen.kt / SettingsViewModel.kt -│ │ └── util/ -│ │ └── BitmapUtils.kt // 缩放 / 旋转 / 复用 -│ ├── assets/ -│ │ ├── model.tflite // YOLOv8n 四类模型 -│ │ └── labels.txt // 类别标签(按 ID 顺序) -│ └── res/ -└── build.gradle.kts -``` - ---- - -## 2. 技术栈与版本 - -| 组件 | 版本(以最新稳定为准) | 用途 | -| --- | --- | --- | -| Kotlin | 2.x | 开发语言 | -| AGP | 8.x | Android Gradle 插件 | -| Jetpack Compose | BOM 2024.x+(Material 3) | UI | -| CameraX | 1.4.x | Preview / ImageAnalysis | -| TensorFlow Lite | 2.16.x | 端侧推理 | -| tensorflow-lite-gpu | 2.16.x | GPU 加速 | -| DataStore | 1.1.x | 设置存储 | - -依赖(build.gradle.kts 关键片段): - -```kotlin -dependencies { - implementation("androidx.camera:camera-core:1.4.1") - implementation("androidx.camera:camera-camera2:1.4.1") - implementation("androidx.camera:camera-lifecycle:1.4.1") - implementation("androidx.camera:camera-view:1.4.1") - - implementation("org.tensorflow:tensorflow-lite:2.16.1") - implementation("org.tensorflow:tensorflow-lite-gpu:2.16.1") - - implementation("androidx.datastore:datastore-preferences:1.1.1") -} -``` - ---- - -## 3. 模块设计 - -### 3.1 camera 模块 — CameraController.kt - -职责:创建并绑定 CameraX 用例(Preview + ImageAnalysis),统一生命周期。 - -```kotlin -class CameraController( - private val lifecycleOwner: LifecycleOwner, - private val analysisExecutor: Executor, -) { - private lateinit var cameraProvider: ProcessCameraProvider - private lateinit var imageAnalysis: ImageAnalysis - - fun start(previewView: PreviewView, analyzer: ImageAnalysis.Analyzer) { - val future = ProcessCameraProvider.getInstance(previewView.context) - future.addListener({ - cameraProvider = future.get() - - val preview = Preview.Builder().build().also { - it.surfaceProvider = previewView.surfaceProvider - } - - imageAnalysis = ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888) - .build() - imageAnalysis.setAnalyzer(analysisExecutor, analyzer) - - cameraProvider.bindToLifecycle( - lifecycleOwner, - CameraSelector.DEFAULT_BACK_CAMERA, - preview, imageAnalysis, - ) - }, ContextCompat.getMainExecutor(previewView.context)) - } -} -``` - -要点: - -- `OUTPUT_IMAGE_FORMAT_RGBA_8888`(CameraX 1.3+)直接得到 RGBA 图像,免 YUV 转换; -- `STRATEGY_KEEP_ONLY_LATEST`:分析器忙时丢弃旧帧,不积压; -- 相机权限检查与请求在进入本模块前完成; -- 前后摄切换:重新以对应 `CameraSelector` 执行 `bindToLifecycle`(解绑旧用例)。 - -### 3.2 camera 模块 — FrameAnalyzer.kt - -职责:帧节流、旋转获取、调度检测、结果回调。 - -```kotlin -class FrameAnalyzer( - private val detector: Detector, - private val onResult: (List, Int) -> Unit, // 结果 + 旋转角 -) : ImageAnalysis.Analyzer { - - private var lastDetectMs = 0L - - override fun analyze(imageProxy: ImageProxy) { - val now = SystemClock.elapsedRealtime() - val interval = frameIntervalMs() // 依据设置:连续/标准/省电 - if (now - lastDetectMs < interval) { - imageProxy.close(); return - } - lastDetectMs = now - - val bitmap = imageProxy.toBitmap() // RGBA_8888 直接转 Bitmap - val rotation = imageProxy.imageInfo.rotationDegrees - try { - val results = detector.detect(bitmap) - onResult(results, rotation) - } finally { - imageProxy.close() // 必须关闭,否则阻塞流 - } - } -} -``` - -### 3.3 detection 模块 — Detector 接口 - -```kotlin -interface Detector { - /** 输入 RGBA 位图,输出归一化坐标检测结果(0..1) */ - fun detect(bitmap: Bitmap): List -} -``` - -### 3.4 detection 模块 — TFLiteDetector.kt - -```kotlin -class TFLiteDetector( - context: Context, - private val inputSize: Int = 320, - private val confThreshold: Float = 0.40f, // 动物类阈值 - private val habitatThreshold: Float = 0.35f, // 生境区域阈值 - private val iouThreshold: Float = 0.45f, - gpuEnabled: Boolean = true, -) : Detector { - - private val labels: List = - context.assets.open("labels.txt").bufferedReader().readLines() - - private val interpreter: Interpreter = Interpreter( - loadModelFile(context, "model.tflite"), - Interpreter.Options().apply { - setNumThreads(4) - if (gpuEnabled) { - try { addDelegate(GpuDelegate()) } catch (_: Exception) { /* 回退 CPU */ } - } - }, - ) - - private val inputBuffer: ByteBuffer = ByteBuffer.allocateDirect( - 1 * inputSize * inputSize * 3 * 4 // float32 - ).order(ByteOrder.nativeOrder()) - - private val outputBuffer: ByteBuffer = ByteBuffer.allocateDirect( - OUTPUT_ELEMENTS * 4 - ).order(ByteOrder.nativeOrder()) - - override fun detect(bitmap: Bitmap): List { - preprocess(bitmap, inputBuffer) // 缩放 + RGB 归一化 → 输入缓冲 - interpreter.run(inputBuffer, outputBuffer) // 单张推理 - return postprocess(outputBuffer, bitmap.width, bitmap.height) - } -} -``` - -输入 / 输出规格(YOLOv8n 四类,320 输入): - -- 输入:`[1, 320, 320, 3]`,RGB,float32 归一化 0~1; -- 输出:`[1, 9, 8400]`(8400 = 各尺度 anchor 数,9 = 4 个框坐标 cx/cy/w/h + 5 类得分),展平为 `9 * 8400` 个 float。 - -### 3.5 detection 模块 — 后处理(解码 + NMS) - -```kotlin -private fun postprocess(raw: FloatArray, imgW: Int, imgH: Int): List { - val numAnchor = raw.size / 9 - val boxes = mutableListOf() - - for (a in 0 until numAnchor) { - val cx = raw[a]; val cy = raw[9 + a] - val w = raw[18 + a]; val h = raw[27 + a] - var bestCls = 0; var bestScore = 0f - for (c in 0 until 5) { - val s = raw[36 + c * numAnchor + a] // 类别得分按 anchor 平铺 - if (s > bestScore) { bestScore = s; bestCls = c } - } - if (bestScore < confThreshold) continue - boxes += DetectionResult( - label = labels[bestCls], - score = bestScore, - // 归一化坐标(裁剪到 [0,1]) - left = (cx - w / 2).coerceIn(0f, 1f), - top = (cy - h / 2).coerceIn(0f, 1f), - right = (cx + w / 2).coerceIn(0f, 1f), - bottom = (cy + h / 2).coerceIn(0f, 1f), - ) - } - return nms(boxes, iouThreshold) -} - -fun nms(boxes: List, iouThreshold: Float): List { - val sorted = boxes.sortedByDescending { it.score } - val kept = mutableListOf() - for (b in sorted) { - if (kept.none { iou(b, it) > iouThreshold }) kept += b - } - return kept -} -``` - -注意:以上取数索引为示意,**必须与模型导出时的输出布局(yolov8 tflite 为 9×8400,按列平铺)核对一致**,建议训练导出后用 Python 脚本先对单图做一致性校验再接入 App。 - -### 3.6 distance 模块 — DistanceEstimator.kt - -```kotlin -class DistanceEstimator(context: Context) { - - private val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager - private var focalPxCache = -1f - - // 物种参考体型(米),用于针孔模型估算 - private val speciesSizeM = mapOf( - "pheasant" to 0.45f, // 身高 - ) - - /** 距离 = 焦距px × 参考体型 / 框高px(在分析图像分辨率下计算) */ - fun estimate(label: String, boxHeightNorm: Float, imageHeightPx: Int): Float? { - val realH = speciesSizeM[label] ?: return null - val boxH = boxHeightNorm * imageHeightPx - if (boxH < 8f) return null // 过小目标不估算 - val focalPx = focalPx(imageHeightPx) - if (focalPx <= 0) return null - return round(focalPx * realH / boxH) - } - - /** focal_px = focal_mm × (imageHeightPx / sensorHeightMm);视场角作回退 */ - private fun focalPx(imageHeightPx: Int): Float { - if (focalPxCache > 0) return focalPxCache - val c = cameraManager.getCameraCharacteristics(cameraManager.cameraIdList.first()) - val focalMm = c.get(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)?.firstOrNull() - val sensor = c.get(CameraCharacteristics.SENSOR_INFO_PHYSICAL_SIZE) - val fov = c.get(CameraCharacteristics.LENS_INFO_HORIZONTAL_VIEW_ANGLE) - focalPxCache = when { - focalMm != null && sensor != null -> focalMm * imageHeightPx / sensor.height - fov != null -> (imageHeightPx / 2f) / tan(fov / 2f) - else -> -1f - } - return focalPxCache - } -} -``` - -说明: - -- 前后摄切换时需按当前相机重新获取焦距(缓存按相机 ID 区分); -- 距离估算仅供参考,实际距离可能因环境因素有所偏差。 - -### 3.7 detection 模块 — CoordinateMapper.kt - -模型输出为归一化坐标(相对分析图像,竖屏方向),需转换到预览视图坐标: - -```kotlin -class CoordinateMapper(private val view: PreviewView) { - - /** 归一化坐标 → 预览视图像素坐标(考虑传感器旋转与 FIT_CENTER 裁剪) */ - fun mapToView(norm: DetectionResult, rotation: Int): RectF { - // 1) 旋转校正:把"图像方向"归一化坐标转到"竖屏视图方向" - val (x0, y0, x1, y1) = rotate(norm, rotation) - // 2) 处理 FIT_CENTER 的裁剪偏移与缩放 - val viewW = view.width; val viewH = view.height - val scale = min(viewW / bitmapW, viewH / bitmapH) // bitmap 尺寸 - val offsetX = (viewW - bitmapW * scale) / 2f - val offsetY = (viewH - bitmapH * scale) / 2f - return RectF( - x0 * bitmapW * scale + offsetX, - y0 * bitmapH * scale + offsetY, - x1 * bitmapW * scale + offsetX, - y1 * bitmapH * scale + offsetY, - ) - } -} -``` - -### 3.8 overlay 模块 — DetectionOverlay.kt - -Compose Canvas 叠加层: - -```kotlin -@Composable -fun DetectionOverlay(results: List, rotation: Int, modifier: Modifier) { - val mapper = remember { CoordinateMapper(view) } - Canvas(modifier = modifier) { - results.forEach { r -> - val rect = mapper.mapToView(r, rotation) - drawRect( - color = colorOf(r.label), - topLeft = Offset(rect.left, rect.top), - size = Size(rect.width(), rect.height()), - style = Stroke(6.dp.toPx()), - ) - // 距离由 ViewModel 计算后写入 DetectionResult.distance(Float?) - val dist = r.distance?.let { " · 约${it}m" } ?: "" - drawText("${r.label} ${(r.score * 100).toInt()}%$dist") - } - } -} -``` - -防闪烁:ViewModel 中对结果做"目标停留 ≥ 0.5s 才显示、消失 2s 后移除"的平滑处理。 - -### 3.9 reminder 模块 — Reminder.kt - -```kotlin -class Reminder(private val context: Context) { - private val vibrator = context.getSystemService(Vibrator::class.java) - private var lastAlertAt = 0L - private var lastAlertLabel: String? = null - - /** 同类目标 10s 内只提醒一次 */ - fun onDetected(label: String) { - val now = SystemClock.elapsedRealtime() - if (label == lastAlertLabel && now - lastAlertAt < 10_000) return - lastAlertAt = now - lastAlertLabel = label - vibrator?.vibrate(VibrationEffect.createOneShot(200, VibrationEffect.DEFAULT_AMPLITUDE)) - } -} -``` - -### 3.10 设置存储 — DataStore - -使用 DataStore Preferences 保存:`conf_threshold`(默认 0.40)、`detect_mode`(连续/标准/省电)、`vibrate_enabled`、`sound_enabled`、`low_light_enhance`(P2)、`show_distance`(默认开)。`SettingsViewModel` 以 Flow 暴露,`FrameAnalyzer` 与 `TFLiteDetector` 读取最新值。 - ---- - -## 4. 关键流程实现 - -### 4.1 启动流程 - -``` -MainActivity.onCreate -→ 检查/请求 CAMERA 权限 -→ 初始化 TFLiteDetector(后台线程,含预热推理) -→ CameraController.start(previewView, analyzer) -→ ViewModel 订阅检测结果 → 叠加层渲染 + 提醒触发 -``` - -预热:加载模型后执行一次空推理(全零输入),避免首帧卡顿。 - -### 4.2 帧节流与并发控制 - -- 检测间隔:连续 80~100ms / 标准 300ms / 省电 1000ms(由设置决定); -- 推理在单线程 Executor(HandlerThread)串行执行,天然互斥; -- 分析线程只做"取帧 → 判断节流 → 提交任务",不做推理,保证预览流畅。 - -### 4.3 提醒、距离标注与防闪烁 - -- 检测到目标(置信度 ≥ 阈值,生境区域用独立阈值)→ `Reminder.onDetected(label)`,10s 防重复,动物与生境预警提醒方式区分; -- 叠加层展示逻辑:目标连续出现 ≥ 0.5s 才绘制;消失后 2s 内不清除,避免闪烁; -- 低置信度结果(阈值以下)不展示、不提醒; -- 距离标注:结果到达后调用 `DistanceEstimator` 估算"约 X m",随检测框渲染(FrameAnalyzer 回调携带分析图像高度);生境区域同样估算(按参考植被高度,误差较大);框高过小(< 8px)或无法获取焦距显示"--"。 - ---- - -## 5. 模型集成细节 - -| 项 | 说明 | -| --- | --- | -| 模型文件 | `app/src/main/assets/model.tflite`,随 APK 打包 | -| 标签文件 | `assets/labels.txt`,每行一个类别,顺序与训练一致(pheasant) | -| 量化 | 优先 fp16(GPU);int8 用于 CPU/低端机回退 | -| 加载 | `Interpreter(loadAssetFile(...))`,进程内单例 | -| 预热 | 初始化后执行一次 dummy run | -| 校验 | 发布前用 Python 脚本(tflite-runtime)对测试集抽样验证模型输出布局与 App 解析一致 | - ---- - -## 6. 性能优化实践 - -| 手段 | 说明 | -| --- | --- | -| GPU Delegate | 优先启用;`addDelegate` 抛异常或首帧异常时回退 CPU,并上报降级日志 | -| 输入缓冲复用 | ByteBuffer 复用,避免每次检测重新分配 | -| 帧节流 | 按模式降频,KEEP_ONLY_LATEST 不积压 | -| 位图复用 | ImageProxy → Bitmap 走共享缓冲;叠加层仅绘制检测框,不复制整帧 | -| 线程模型 | 检测线程单线程串行;UI 线程零推理 | -| 低分辨率检测 | 320 输入检测;远距离模式可切 416(V2) | -| 距离标注 | 纯算术运算开销可忽略;焦距 px 按相机缓存复用,避免重复查询 CameraCharacteristics | -| 生命周期 | 后台自动 `unbind` 相机释放资源,避免耗电 | -| 省电模式 | 降到 1 帧/秒 + 低分辨率 + 关闭提醒以外的动画 | - ---- - -## 7. 异常与降级策略 - -| 异常场景 | 处理 | -| --- | --- | -| GPU 不可用 / 模型加载失败 | 回退 CPU 线程数调整;失败则提示"识别不可用"但不影响相机预览 | -| 相机初始化失败(个别机型) | 重试一次 → 失败提示并引导检查权限/重启 | -| 权限被拒 | 引导页说明用途,提供跳转系统设置 | -| 低内存 | 主动释放缓存位图,降低检测分辨率 | -| 推理超时(> 500ms) | 丢弃该帧结果,恢复下一帧,保证预览流畅 | -| 无法读取焦距 / 视场角(个别机型) | 距离显示"--",识别不受影响 | - ---- - -## 8. 测试方案 - -### 8.1 单元测试(JUnit) - -- `NmsTest`:NMS 正确性(重叠/多类别/边界框); -- `CoordinateMapperTest`:四向旋转映射、FIT_CENTER 裁剪偏移; -- `ThresholdTest`:置信度阈值过滤逻辑; -- `ReminderTest`:10s 防重复提醒逻辑、动物与生境提醒方式区分; -- `DistanceEstimatorTest`:焦距换算、距离公式、过小目标不估算、生境区域按植被高度估算。 - -### 8.2 模型评估(Python 脚本,独立于 App) - -- 对测试集计算 mAP@0.5、mAP@0.5:0.95、各类别 AP、负样本误检率; -- 输出混淆矩阵,分析检测精度。 - -### 8.3 仪器测试(androidTest) - -- 模拟帧注入:将测试图片经 `ImageProxy` 注入分析器,断言结果(不依赖真机取景); -- 权限拒绝 / 恢复场景; -- 冷启动到预览的时间基准测试。 - -### 8.4 真机测试矩阵 - -| 机型档位 | 代表机型 | 验证项 | -| --- | --- | --- | -| 旗舰 | 骁龙 8 系 / 麒麟 9 系 | 全功能、GPU 路径、长时间发热 | -| 中端 | 骁龙 7 系 / 天玑 8 系 | 延迟 ≤ 80ms、耗电 ≤ 15%/h | -| 低端 | 骁龙 4 系 / 天玑 6 系 | CPU 回退路径、省电模式可用性 | - ---- - -## 9. 构建与发布 - -```kotlin -android { - compileSdk = 35 - defaultConfig { - applicationId = "com.example.observer" - minSdk = 24 - targetSdk = 35 - ndk { abiFilters += listOf("arm64-v8a") } // 主发 arm64;如需兼容 32 位另发包 - } - buildTypes { - release { - isMinifyEnabled = true - isShrinkResources = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - } - } -} -``` - -- R8 规则:保留 TFLite 相关类(`-keep class org.tensorflow.** { *; }`),assets 模型不混淆; -- 签名:正式签名 + Gradle 管理(或环境变量注入),不上传 keystore 到仓库; -- 上架检查:隐私政策(仅相机权限、不采集与存储数据声明)、权限用途说明。 - ---- - -## 10. 后续演进(V2 方向) - -- 更多物种类别(哺乳类、鸟类细分),支持模型远程更新; -- 小目标优化:SAHI 切图推理 / 专用小目标模型 / 数字变焦辅助; -- 夜视 / 红外增强模式; -- 检测结果语音播报,提升无障碍体验; -- 距离精度增强:基于设备俯仰角与相机高度的地面平面法、镜头畸变校正、水下折射修正; -- 生境类别细分与精度提升(湿地、林缘等),生境预警策略优化; -- 若未来需要留存能力(拍照、记录),可基于现有检测链路平滑扩展。 diff --git a/flutter_app/.gitignore b/flutter_app/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/flutter_app/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/flutter_app/.metadata b/flutter_app/.metadata new file mode 100644 index 0000000..0fcff68 --- /dev/null +++ b/flutter_app/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "058e0af2c2b57e369d905a03ac9748b0ebf543c6" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + - platform: ios + create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/flutter_app/README.md b/flutter_app/README.md new file mode 100644 index 0000000..2561190 --- /dev/null +++ b/flutter_app/README.md @@ -0,0 +1,33 @@ +# observer + +野生动物实时识别 App(Flutter 版)。Android / iOS 一套代码,后端接口与支付见 +[`docs/PaymentApi.md`](docs/PaymentApi.md)。 + +## iOS 真机部署(iPhone) + +### 构建与安装 + +```bash +# 真机必须传 Mac 局域网 IP:默认 API_BASE_URL 是 10.0.2.2(仅 Android 模拟器可用), +# 不传则 iPhone 上所有网络请求(登录/授权/套餐)都会失败 +flutter build ios --release --dart-define=API_BASE_URL=http://:8080 + +# 安装到真机(UDID 可用 `xcrun devicectl list devices` 查询) +xcrun devicectl device install app --device build/ios/iphoneos/Runner.app + +# 启动并抓控制台日志(--terminate-existing 先杀掉旧实例) +xcrun devicectl device process launch --console --terminate-existing \ + --device com.observer.app +``` + +### 注意事项(踩过的坑) + +- **debug 构建不能在真机上从桌面图标启动**:iOS 14+ 会提示 + "In iOS 14+, debug mode Flutter apps can only be launched from Flutter tooling"。 + debug 调试必须用 `flutter run -d <设备ID>` 或 Xcode IDE 启动(`flutter devices` 查设备ID); + 从图标启动只对 release 构建有效。 +- **模型输入是 NHWC**:`assets/model.tflite` 做过字节级手术(开头 TRANSPOSE→RESHAPE, + 输入 [1,320,320,3]),改动记录见 git 历史,重导模型需同步处理,否则 iOS 报 + "Node number 0 (TRANSPOSE) failed to prepare"。 +- **模拟器黑屏**:本机 iOS 模拟器 Impeller 渲染黑屏,验证 UI 用 VM service + (`flutter run` 输出里的 DevTools 地址),或直接真机验证。 diff --git a/flutter_app/analysis_options.yaml b/flutter_app/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/flutter_app/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/flutter_app/android/.gitignore b/flutter_app/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/flutter_app/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/flutter_app/android/app/build.gradle.kts b/flutter_app/android/app/build.gradle.kts new file mode 100644 index 0000000..008a796 --- /dev/null +++ b/flutter_app/android/app/build.gradle.kts @@ -0,0 +1,53 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.observer" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.observer" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +// tflite_flutter 依赖的 tensorflow-lite / tensorflow-lite-gpu / tensorflow-lite-api 三个 AAR +// 声明了相同 namespace(org.tensorflow.lite),新 AGP 视作冲突直接报错; +// 本项目仅用 CPU 推理,GPU delegate 未使用,排除 gpu 及其传递依赖的 api 即可。 +configurations.all { + exclude(group = "org.tensorflow", module = "tensorflow-lite-gpu") + exclude(group = "org.tensorflow", module = "tensorflow-lite-api") +} + +flutter { + source = "../.." +} diff --git a/flutter_app/android/app/src/debug/AndroidManifest.xml b/flutter_app/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/flutter_app/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/flutter_app/android/app/src/main/AndroidManifest.xml b/flutter_app/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0ed652d --- /dev/null +++ b/flutter_app/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter_app/android/app/src/main/kotlin/com/example/observer/MainActivity.kt b/flutter_app/android/app/src/main/kotlin/com/example/observer/MainActivity.kt new file mode 100644 index 0000000..36d1984 --- /dev/null +++ b/flutter_app/android/app/src/main/kotlin/com/example/observer/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.observer + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml b/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/flutter_app/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/flutter_app/android/app/src/main/res/drawable/launch_background.xml b/flutter_app/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/flutter_app/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/flutter_app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..db77bb4b7b0906d62b1847e87f15cdcacf6a4f29 GIT binary patch literal 544 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~WQl7;NpOBzNqJ&XDuZK6ep0G} zXKrG8YEWuoN@d~6R2!h8bpbvhu0Wd6uZuB!w&u2PAxD2eNXD>P5D~Wn-+_Wa#27Xc zC?Zj|6r#X(-D3u$NCt}(Ms06KgJ4FxJVv{GM)!I~&n8Bnc94O7-Hd)cjDZswgC;Qs zO=b+9!WcT8F?0rF7!Uys2bs@gozCP?z~o%U|N3vA*22NaGQG zlg@K`O_XuxvZ&Ks^m&R!`&1=spLvfx7oGDKDwpwW`#iqdw@AL`7MR}m`rwr|mZgU`8P7SBkL78fFf!WnuYWm$5Z0 zNXhDbCv&49sM544K|?c)WrFfiZvCi9h0O)B3Pgg&ebxsLQ05GG~ AQ2+n{ literal 0 HcmV?d00001 diff --git a/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/flutter_app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..17987b79bb8a35cc66c3c1fd44f5a5526c1b78be GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA3?vioaBc-sk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5Xx&nMcT!A!W`0S9QKQy;}1Cl^CgaH=;G9cpY;r$Q>i*pfB zP2drbID<_#qf;rPZx^FqH)F_D#*k@@q03KywUtLX8Ua?`H+NMzkczFPK3lFz@i_kW%1NOn0|D2I9n9wzH8m|-tHjsw|9>@K=iMBhxvkv6m8Y-l zytQ?X=U+MF$@3 zt`~i=@j|6y)RWMK--}M|=T`o&^Ni>IoWKHEbBXz7?A@mgWoL>!*SXo`SZH-*HSdS+ yn*9;$7;m`l>wYBC5bq;=U}IMqLzqbYCidGC!)_gkIk_C@Uy!y&wkt5C($~2D>~)O*cj@FGjOCM)M>_ixfudOh)?xMu#Fs z#}Y=@YDTwOM)x{K_j*Q;dPdJ?Mz0n|pLRx{4n|)f>SXlmV)XB04CrSJn#dS5nK2lM zrZ9#~WelCp7&e13Y$jvaEXHskn$2V!!DN-nWS__6T*l;H&Fopn?A6HZ-6WRLFP=R` zqG+CE#d4|IbyAI+rJJ`&x9*T`+a=p|0O(+s{UBcyZdkhj=yS1>AirP+0R;mf2uMgM zC}@~JfByORAh4SyRgi&!(cja>F(l*O+nd+@4m$|6K6KDn_&uvCpV23&>G9HJp{xgg zoq1^2_p9@|WEo z*X_Uko@K)qYYv~>43eQGMdbiGbo>E~Q& zrYBH{QP^@Sti!`2)uG{irBBq@y*$B zi#&(U-*=fp74j)RyIw49+0MRPMRU)+a2r*PJ$L5roHt2$UjExCTZSbq%V!HeS7J$N zdG@vOZB4v_lF7Plrx+hxo7(fCV&}fHq)$ literal 0 HcmV?d00001 diff --git a/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/flutter_app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..d5f1c8d34e7a88e3f88bea192c3a370d44689c3c GIT binary patch literal 1031 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q8Ax83A=Cw=BuiW)N`mv#O3D+9QW+dm@{>{( zJaZG%Q-e|yQz{EjrrIztFa`(sgt!6~Yi|1%a`XoT0ojZ}lNrNjb9xjc(B0U1_% zz5^97Xt*%oq$rQy4?0GKNfJ44uvxI)gC`h-NZ|&0-7(qS@?b!5r36oQ}zyZrNO3 zMO=Or+<~>+A&uN&E!^Sl+>xE!QC-|oJv`ApDhqC^EWD|@=#J`=d#Xzxs4ah}w&Jnc z$|q_opQ^2TrnVZ0o~wh<3t%W&flvYGe#$xqda2bR_R zvPYgMcHgjZ5nSA^lJr%;<&0do;O^tDDh~=pIxA#coaCY>&N%M2^tq^U%3DB@ynvKo}b?yu-bFc-u0JHzced$sg7S3zqI(2 z#Km{dPr7I=pQ5>FuK#)QwK?Y`E`B?nP+}U)I#c1+FM*1kNvWG|a(TpksZQ3B@sD~b zpQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/flutter_app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`YV6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_MsH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8=l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$%4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8ht^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z%$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z~7YxD~Rf<(a@_y` literal 0 HcmV?d00001 diff --git a/flutter_app/android/app/src/main/res/values-night/styles.xml b/flutter_app/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/flutter_app/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/flutter_app/android/app/src/main/res/values/styles.xml b/flutter_app/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/flutter_app/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/flutter_app/android/app/src/profile/AndroidManifest.xml b/flutter_app/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/flutter_app/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/flutter_app/android/build.gradle.kts b/flutter_app/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/flutter_app/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/flutter_app/android/gradle.properties b/flutter_app/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/flutter_app/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/gradle/wrapper/gradle-wrapper.properties b/flutter_app/android/gradle/wrapper/gradle-wrapper.properties similarity index 66% rename from gradle/wrapper/gradle-wrapper.properties rename to flutter_app/android/gradle/wrapper/gradle-wrapper.properties index 1ef00a1..2d428bf 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/flutter_app/android/gradle/wrapper/gradle-wrapper.properties @@ -1,9 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip -networkTimeout=10000 -retries=0 -retryBackOffMs=500 -validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/flutter_app/android/settings.gradle.kts b/flutter_app/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/flutter_app/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/flutter_app/assets/beep.wav b/flutter_app/assets/beep.wav new file mode 100644 index 0000000000000000000000000000000000000000..8a7cb08ae107915c63d87b8b8e53bc3531a2f93d GIT binary patch literal 12392 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识别入口(搜索按钮)必须强制服务端校验授权**,不走本地缓存。 + +- Base URL: `AppConfig.apiBaseUrl`(占位 `https://YOUR_BACKEND.example.com`) +- 响应统一格式: `{"code": 0, "message": "ok", "data": {...}}`,`code != 0` 视为失败;登录失效返回 `code 61`,客户端应回登录页 +- 授权语义: 自然日(当天 24:00 失效 / 7 天 / 30 天),以服务端为准 +- 账号标识: 手机号(服务端 license 表即账号表,手机号为主键;无独立用户表) + +## 1. 注册 + +`POST /api/v1/auth/register`(公开,无需登录) + +```json +{"phone": "13800138000", "password": "pass123456"} +``` + +响应 `data` 为空对象。重复注册报错;已被运营手动授权(发卡占位行)的手机号可注册补密码,授权保留。 + +## 2. 登录 + +`POST /api/v1/auth/login`(公开,无需登录) + +```json +{"phone": "13800138000", "password": "pass123456"} +``` + +响应 `data`: + +```json +{"token": ""} +``` + +- token 无状态,有效期 `auth.tokenTtl`(默认 30 天),客户端存 secure storage +- 后续所有接口携带 `Authorization: Bearer ` + +## 3. 创建订单 + +`POST /api/v1/orders`(需登录) + +```json +{"planId": "day|week|month", "channel": "wechat|alipay"} +``` + +响应 `data`: + +```json +{ + "orderId": "O20260822001", + "payParams": { + "partnerId": "1900xxxxx", "prepayId": "wx...", "nonceStr": "...", + "timeStamp": "1728000000", "sign": "...", "packageValue": "Sign=WXPay" + } +} +``` + +- 手机号由 token 识别,请求体不含 deviceId +- `channel == wechat` 时 `payParams` 为微信 APP 支付下单参数 +- `channel == alipay` 时 `payParams` 为 `{"orderStr": "alipay_sdk=..."}` + +## 4. 支付结果确认 + +`POST /api/v1/orders/{orderId}/confirm`(需登录) + +```json +{} +``` + +客户端拉起 SDK 支付成功后调用(幂等)。服务端以微信/支付宝异步回调为准落授权;confirm 仅用于加速刷新。响应 `data: {"status": "paid|created|closed"}`。 + +## 5. 查询授权 + +`GET /api/v1/license`(需登录) + +响应 `data`: + +```json +{"active": true, "expiresAt": "2026-08-29T23:59:59+08:00"} +``` + +- `active: false` 或 `expiresAt` 已过期 → 客户端展示付费墙/充值入口 +- **识别入口(搜索按钮)必须调本接口做强制校验**:网络失败视为不可用并提示,禁止用本地缓存放行 +- 主界面到期时间展示可用本地缓存(服务端为准,刷新时覆盖) + +## 6. 套餐 + +`GET /api/v1/plans`(需登录)拉取价格方案,**客户端不硬编码价格**(进充值页时拉取,价格以服务端为准)。 + +响应 `data`: + +```json +{ + "list": [ + {"planId": "day", "days": 1, "priceCents": 1000}, + {"planId": "week", "days": 7, "priceCents": 5600}, + {"planId": "month", "days": 30, "priceCents": 18000} + ] +} +``` + +- `priceCents` 为整数分,客户端展示 ÷100 转元 +- 展示名由客户端按 `days` 派生「N天」,接口无 label 字段 +- 后端套餐来自 `config.yml` `plans` 节点(静态定价,改价改配置重启生效) diff --git a/flutter_app/ios/.gitignore b/flutter_app/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/flutter_app/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/flutter_app/ios/Flutter/AppFrameworkInfo.plist b/flutter_app/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/flutter_app/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/flutter_app/ios/Flutter/Debug.xcconfig b/flutter_app/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/flutter_app/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/flutter_app/ios/Flutter/Release.xcconfig b/flutter_app/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/flutter_app/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/flutter_app/ios/Podfile b/flutter_app/ios/Podfile new file mode 100644 index 0000000..b7c1ed5 --- /dev/null +++ b/flutter_app/ios/Podfile @@ -0,0 +1,66 @@ +platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end + + # 微信 SDK podspec 对模拟器保守排除 arm64(其 xcframework 实际含 arm64 slice), + # 不清除会导致 M 系 Mac 上模拟器构建被压成 x86_64 而无法安装运行。 + wechat = installer.pods_project.targets.find { |t| t.name == 'WechatOpenSDK-XCFramework' } + wechat&.build_configurations&.each do |config| + config.build_settings['EXCLUDED_ARCHS[sdk=iphonesimulator*]'] = '' + end + + # WechatOpenSDK-XCFramework 的 Headers 不会自动进入依赖方搜索路径 + # (CocoaPods 对 vendored XCFramework 的已知行为),fluwx 以引号引入 + # WXApi.h 需要显式补充头文件搜索路径。 + wechat_headers = Dir.glob( + File.join(Pod::Config.instance.project_root, 'Pods', 'WechatOpenSDK-XCFramework', + 'WechatOpenSDK.xcframework', '*', 'WechatOpenSDK.framework', 'Headers') + ) + unless wechat_headers.empty? + fluwx = installer.pods_project.targets.find { |t| t.name == 'fluwx' } + fluwx&.build_configurations&.each do |config| + config.build_settings['HEADER_SEARCH_PATHS'] = [ + '$(inherited)', + *wechat_headers.map { |p| "\"#{p}\"" }, + ].join(' ') + end + end +end diff --git a/flutter_app/ios/Podfile.lock b/flutter_app/ios/Podfile.lock new file mode 100644 index 0000000..645765e --- /dev/null +++ b/flutter_app/ios/Podfile.lock @@ -0,0 +1,83 @@ +PODS: + - Flutter (1.0.0) + - flutter_secure_storage (6.0.0): + - Flutter + - fluwx (0.0.1): + - Flutter + - fluwx/pay (= 0.0.1) + - fluwx/pay (0.0.1): + - Flutter + - WechatOpenSDK-XCFramework (~> 2.0.4) + - TensorFlowLiteC (2.12.0): + - TensorFlowLiteC/Core (= 2.12.0) + - TensorFlowLiteC/Core (2.12.0) + - TensorFlowLiteC/CoreML (2.12.0): + - TensorFlowLiteC/Core + - TensorFlowLiteC/Metal (2.12.0): + - TensorFlowLiteC/Core + - TensorFlowLiteSwift (2.12.0): + - TensorFlowLiteSwift/Core (= 2.12.0) + - TensorFlowLiteSwift/Core (2.12.0): + - TensorFlowLiteC (= 2.12.0) + - TensorFlowLiteSwift/CoreML (2.12.0): + - TensorFlowLiteC/CoreML (= 2.12.0) + - TensorFlowLiteSwift/Core (= 2.12.0) + - TensorFlowLiteSwift/Metal (2.12.0): + - TensorFlowLiteC/Metal (= 2.12.0) + - TensorFlowLiteSwift/Core (= 2.12.0) + - tflite_flutter (0.0.1): + - Flutter + - TensorFlowLiteSwift (= 2.12.0) + - TensorFlowLiteSwift/CoreML (= 2.12.0) + - TensorFlowLiteSwift/Metal (= 2.12.0) + - tobias (0.0.1): + - Flutter + - tobias/normal (= 0.0.1) + - tobias/normal (0.0.1): + - Flutter + - vibration (1.7.5): + - Flutter + - WechatOpenSDK-XCFramework (2.0.7) + +DEPENDENCIES: + - Flutter (from `Flutter`) + - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - fluwx (from `.symlinks/plugins/fluwx/ios`) + - tflite_flutter (from `.symlinks/plugins/tflite_flutter/ios`) + - tobias (from `.symlinks/plugins/tobias/ios`) + - vibration (from `.symlinks/plugins/vibration/ios`) + +SPEC REPOS: + trunk: + - TensorFlowLiteC + - TensorFlowLiteSwift + - WechatOpenSDK-XCFramework + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + flutter_secure_storage: + :path: ".symlinks/plugins/flutter_secure_storage/ios" + fluwx: + :path: ".symlinks/plugins/fluwx/ios" + tflite_flutter: + :path: ".symlinks/plugins/tflite_flutter/ios" + tobias: + :path: ".symlinks/plugins/tobias/ios" + vibration: + :path: ".symlinks/plugins/vibration/ios" + +SPEC CHECKSUMS: + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + fluwx: 6bf9c5a3a99ad31b0de137dd92370a0d10a60f4b + TensorFlowLiteC: 20785a69299185a379ba9852b6625f00afd7984a + TensorFlowLiteSwift: 3a4928286e9e35bdd3e17970f48e53c80d25e793 + tflite_flutter: 64b192e11352fe36943ab6656e1d49207f1a5595 + tobias: 7bc370eaccba2e7c7c345902a4a47dc5916cf8bb + vibration: 8e2f50fc35bb736f9eecb7dd9f7047fbb6a6e888 + WechatOpenSDK-XCFramework: 5df9b250e9839dcc306ad8b00f46822eead1ed47 + +PODFILE CHECKSUM: bee538157bfc80e3e10d48a376da3677058aaad6 + +COCOAPODS: 1.17.0 diff --git a/flutter_app/ios/Runner.xcodeproj/project.pbxproj b/flutter_app/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..5985cea --- /dev/null +++ b/flutter_app/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,782 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 847056A2B331EBEF7FE4658D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 44D6E292382D4D067EB56539 /* Pods_Runner.framework */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + 9926B0B1CD9F66E289877411 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 324F3F864FEBF7907AB5D311 /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 11C083119EC1D6C49118DBAB /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 324F3F864FEBF7907AB5D311 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 352A4BA150122CC2D593B1FF /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 40021A3125FEC9A6F074E182 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 44D6E292382D4D067EB56539 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 542B41CC0975A03C978D0E7D /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 6CCFCD2B5C2A6DB29ED6FEE9 /* Runner.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 95A9E03BE9A266B55717C203 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B89928717BDFD7B081FE98E3 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 8A8301CDECC0F90E622D6F74 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9926B0B1CD9F66E289877411 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + 847056A2B331EBEF7FE4658D /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 684998EFA363788852AAAD42 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 44D6E292382D4D067EB56539 /* Pods_Runner.framework */, + 324F3F864FEBF7907AB5D311 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + F8827B4F9A04615E3B2278F0 /* Pods */, + 684998EFA363788852AAAD42 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + 6CCFCD2B5C2A6DB29ED6FEE9 /* Runner.entitlements */, + ); + path = Runner; + sourceTree = ""; + }; + F8827B4F9A04615E3B2278F0 /* Pods */ = { + isa = PBXGroup; + children = ( + 352A4BA150122CC2D593B1FF /* Pods-Runner.debug.xcconfig */, + 11C083119EC1D6C49118DBAB /* Pods-Runner.release.xcconfig */, + 95A9E03BE9A266B55717C203 /* Pods-Runner.profile.xcconfig */, + B89928717BDFD7B081FE98E3 /* Pods-RunnerTests.debug.xcconfig */, + 542B41CC0975A03C978D0E7D /* Pods-RunnerTests.release.xcconfig */, + 40021A3125FEC9A6F074E182 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 435F2587C2B98BB6FE35CA42 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 8A8301CDECC0F90E622D6F74 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 1A05B1452EF4ACCDC4D1537B /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 8C9F4CABE92F7C7C5DE4A971 /* [CP] Embed Pods Frameworks */, + 8E27EE045B33D54A69365374 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 1A05B1452EF4ACCDC4D1537B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 435F2587C2B98BB6FE35CA42 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 8C9F4CABE92F7C7C5DE4A971 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 8E27EE045B33D54A69365374 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = QRN5J857S2; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.observer.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B89928717BDFD7B081FE98E3 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.observer.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 542B41CC0975A03C978D0E7D /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.observer.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 40021A3125FEC9A6F074E182 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.observer.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = QRN5J857S2; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.observer.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = QRN5J857S2; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.observer.app; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/flutter_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/flutter_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/flutter_app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/flutter_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flutter_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/flutter_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/flutter_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/flutter_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/flutter_app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/flutter_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/flutter_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/flutter_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter_app/ios/Runner.xcworkspace/contents.xcworkspacedata b/flutter_app/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/flutter_app/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/flutter_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/flutter_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/flutter_app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/flutter_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/flutter_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/flutter_app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/flutter_app/ios/Runner/AppDelegate.swift b/flutter_app/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/flutter_app/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..dc9ada4725e9b0ddb1deab583e5b5102493aa332 GIT binary patch literal 10932 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 literal 0 HcmV?d00001 diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..797d452e458972bab9d994556c8305db4c827017 GIT binary patch literal 406 zcmV;H0crk;P))>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..6ed2d933e1120817fe9182483a228007b18ab6ae GIT binary patch literal 450 zcmV;z0X_bSP)iGWQ_5NJQ_~rNh*z)}eT%KUb z`7gNk0#AwF^#0T0?hIa^`~Ck;!}#m+_uT050aTR(J!bU#|IzRL%^UsMS#KsYnTF*!YeDOytlP4VhV?b} z%rz_<=#CPc)tU1MZTq~*2=8~iZ!lSa<{9b@2Jl;?IEV8)=fG217*|@)CCYgFze-x? zIFODUIA>nWKpE+bn~n7;-89sa>#DR>TSlqWk*!2hSN6D~Qb#VqbP~4Fk&m`@1$JGr zXPIdeRE&b2Thd#{MtDK$px*d3-Wx``>!oimf%|A-&-q*6KAH)e$3|6JV%HX{Hig)k suLT-RhftRq8b9;(V=235Wa|I=027H2wCDra;{X5v07*qoM6N<$f;9x^2LJ#7 literal 0 HcmV?d00001 diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..4cd7b0099ca80c806f8fe495613e8d6c69460d76 GIT binary patch literal 282 zcmV+#0p(^bcu7P-R4C8Q z&e;xxFbF_Vrezo%_kH*OKhshZ6BFpG-Y1e10`QXJKbND7AMQ&cMj60B5TNObaZxYybcN07*qoM6N<$g3m;S%K!iX literal 0 HcmV?d00001 diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fe730945a01f64a61e2235dbe3f45b08f7729182 GIT binary patch literal 462 zcmV;<0WtoGP)-}iV`2<;=$?g5M=KQbZ{F&YRNy7Nn@%_*5{gvDM0aKI4?ESmw z{NnZg)A0R`+4?NF_RZexyVB&^^ZvN!{I28tr{Vje;QNTz`dG&Jz0~Ek&f2;*Z7>B|cg}xYpxEFY+0YrKLF;^Q+-HreN0P{&i zK~zY`?b7ECf-n?@;d<&orQ*Q7KoR%4|C>{W^h6@&01>0SKS`dn{Q}GT%Qj_{PLZ_& zs`MFI#j-(>?bvdZ!8^xTwlY{qA)T4QLbY@j(!YJ7aXJervHy6HaG_2SB`6CC{He}f zHVw(fJWApwPq!6VY7r1w-Fs)@ox~N+q|w~e;JI~C4Vf^@d>Wvj=fl`^u9x9wd9 zR%3*Q+)t%S!MU_`id^@&Y{y7-r98lZX0?YrHlfmwb?#}^1b{8g&KzmkE(L>Z&)179 zp<)v6Y}pRl100G2FL_t(o!|l{-Q-VMg#&MKg7c{O0 z2wJImOS3Gy*Z2Qifdv~JYOp;v+U)a|nLoc7hNH;I$;lzDt$}rkaFw1mYK5_0Q(Sut zvbEloxON7$+HSOgC9Z8ltuC&0OSF!-mXv5caV>#bc3@hBPX@I$58-z}(ZZE!t-aOG zpjNkbau@>yEzH(5Yj4kZiMH32XI!4~gVXNnjAvRx;Sdg^`>2DpUEwoMhTs_st8pKG z(%SHyHdU&v%f36~uERh!bd`!T2dw;z6PrOTQ7Vt*#9F2uHlUVnb#ev_o^fh}Dzmq} zWtlk35}k=?xj28uO|5>>$yXadTUE@@IPpgH`gJ~Ro4>jd1IF|(+IX>8M4Ps{PNvmI zNj4D+XgN83gPt_Gm}`Ybv{;+&yu-C(Grdiahmo~BjG-l&mWM+{e5M1sm&=xduwgM9 z`8OEh`=F3r`^E{n_;%9weN{cf2%7=VzC@cYj+lg>+3|D|_1C@{hcU(DyQG_BvBWe? zvTv``=%b1zrol#=R`JB)>cdjpWt&rLJgVp-t?DREyuq1A%0Z4)6_WsQ7{nzjN zo!X zGXV)2i3kcZIL~_j>uIKPK_zib+3T+Nt3Mb&Br)s)UIaA}@p{wDda>7=Q|mGRp7pqY zkJ!7E{MNz$9nOwoVqpFb)}$IP24Wn2JJ=Cw(!`OXJBr45rP>>AQr$6c7slJWvbpNW z@KTwna6d?PP>hvXCcp=4F;=GR@R4E7{4VU^0p4F>v^#A|>07*qoM6N<$f*5nx ACIA2c literal 0 HcmV?d00001 diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..502f463a9bc882b461c96aadf492d1729e49e725 GIT binary patch literal 586 zcmV-Q0=4~#P)+}#`wDE{8-2Mebf5<{{PqV{TgVcv*r8?UZ3{-|G?_}T*&y;@cqf{ z{Q*~+qr%%p!1pS*_Uicl#q9lc(D`!D`LN62sNwq{oYw(Wmhk)k<@f$!$@ng~_5)Ru z0Z)trIA5^j{DIW^c+vT2%lW+2<(RtE2wR;4O@)Tm`Xr*?A(qYoM}7i5Yxw>D(&6ou zxz!_Xr~yNF+waPe00049Nkl*;a!v6h%{rlvIH#gW3s8p;bFr=l}mRqpW2h zw=OA%hdyL~z+UHOzl0eKhEr$YYOL-c-%Y<)=j?(bzDweB7{b+%_ypvm_cG{SvM=DK zhv{K@m>#Bw>2W$eUI#iU)Wdgs8Y3U+A$Gd&{+j)d)BmGKx+43U_!tik_YlN)>$7G! zhkE!s;%oku3;IwG3U^2kw?z+HM)jB{@zFhK8P#KMSytSthr+4!c(5c%+^UBn`0X*2 zy3(k600_CSZj?O$Qu%&$;|TGUJrptR(HzyIx>5E(2r{eA(<6t3e3I0B)7d6s7?Z5J zZ!rtKvA{MiEBm&KFtoifx>5P^Z=vl)95XJn()aS5%ad(s?4-=Tkis9IGu{`Fy8r+H07*qoM6N<$f20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0ec303439225b78712f49115768196d8d76f6790 GIT binary patch literal 862 zcmV-k1EKthP)20Z)wqMt%V?S?~D#06};F zA3KcL`Wb+>5ObvgQIG&ig8(;V04hz?@cqy3{mSh8o!|U|)cI!1_+!fWH@o*8vh^CU z^ws0;(c$gI+2~q^tO#GDHf@=;DncUw00J^eL_t(&-tE|HQ`%4vfZ;WsBqu-$0nu1R zq^Vj;p$clf^?twn|KHO+IGt^q#a3X?w9dXC@*yxhv&l}F322(8Y1&=P&I}~G@#h6; z1CV9ecD9ZEe87{{NtI*)_aJ<`kJa z?5=RBtFF50s;jQLFil-`)m2wrb=6h(&brpj%nG_U&ut~$?8Rokzxi8zJoWr#2dto5 zOX_URcc<1`Iky+jc;A%Vzx}1QU{2$|cKPom2Vf1{8m`vja4{F>HS?^Nc^rp}xo+Nh zxd}eOm`fm3@MQC1< zIk&aCjb~Yh%5+Yq0`)D;q{#-Uqlv*o+Oor zE!I71Z@ASH3grl8&P^L0WpavHoP|UX4e?!igT`4?AZk$hu*@%6WJ;zDOGlw7kj@ zY5!B-0ft0f?Lgb>C;$Ke07*qoM6N<$f~t1N9smFU literal 0 HcmV?d00001 diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f5fea27c705180eb716271f41b582e76dcbd90 GIT binary patch literal 1674 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 literal 0 HcmV?d00001 diff --git a/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/flutter_app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0467bf12aa4d28f374bb26596605a46dcbb3e7c8 GIT binary patch literal 1418 zcmV;51$Fv~P)q zKfU)WzW*n(@|xWGCA9ScMt*e9`2kdxPQ&&>|-UCa7_51w+ zLUsW@ZzZSW0y$)Hp~e9%PvP|a03ks1`~K?q{u;6NC8*{AOqIUq{CL&;p56Lf$oQGq z^={4hPQv)y=I|4n+?>7Fim=dxt1 z2H+Dm+1+fh+IF>G0SjJMkQQre1x4|G*Z==(Ot&kCnUrL4I(rf(ucITwmuHf^hXiJT zkdTm&kdTm&kdTm&kdP`esgWG0BcWCVkVZ&2dUwN`cgM8QJb`Z7Z~e<&Yj2(}>Tmf` zm1{eLgw!b{bXkjWbF%dTkTZEJWyWOb##Lfw4EK2}<0d6%>AGS{po>WCOy&f$Tay_> z?NBlkpo@s-O;0V%Y_Xa-G#_O08q5LR*~F%&)}{}r&L%Sbs8AS4t7Y0NEx*{soY=0MZExqA5XHQkqi#4gW3 zqODM^iyZl;dvf)-bOXtOru(s)Uc7~BFx{w-FK;2{`VA?(g&@3z&bfLFyctOH!cVsF z7IL=fo-qBndRUm;kAdXR4e6>k-z|21AaN%ubeVrHl*<|s&Ax@W-t?LR(P-24A5=>a z*R9#QvjzF8n%@1Nw@?CG@6(%>+-0ASK~jEmCV|&a*7-GKT72W<(TbSjf)&Eme6nGE z>Gkj4Sq&2e+-G%|+NM8OOm5zVl9{Z8Dd8A5z3y8mZ=4Bv4%>as_{9cN#bm~;h>62( zdqY93Zy}v&c4n($Vv!UybR8ocs7#zbfX1IY-*w~)p}XyZ-SFC~4w>BvMVr`dFbelV{lLL0bx7@*ZZdebr3`sP;? zVImji)kG)(6Juv0lz@q`F!k1FE;CQ(D0iG$wchPbKZQELlsZ#~rt8#90Y_Xh&3U-< z{s<&cCV_1`^TD^ia9!*mQDq& zn2{r`j};V|uV%_wsP!zB?m%;FeaRe+X47K0e+KE!8C{gAWF8)lCd1u1%~|M!XNRvw zvtqy3iz0WSpWdhn6$hP8PaRBmp)q`#PCA`Vd#Tc$@f1tAcM>f_I@bC)hkI9|o(Iqv zo}Piadq!j76}004RBio<`)70k^`K1NK)q>w?p^C6J2ZC!+UppiK6&y3Kmbv&O!oYF z34$0Z;QO!JOY#!`qyGH<3Pd}Pt@q*A0V=3SVtWKRR8d8Z&@)3qLPA19LPA19LPEUC YUoZo%k(ykuW&i*H07*qoM6N<$f+CH{y8r+H literal 0 HcmV?d00001 diff --git a/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..9da19eacad3b03bb08bbddbbf4ac48dd78b3d838 GIT binary patch literal 68 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx0wlM}@Gt=>Zci7-kcv6Uzs@r-FtIZ-&5|)J Q1PU{Fy85}Sb4q9e0B4a5jsO4v literal 0 HcmV?d00001 diff --git a/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/flutter_app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/flutter_app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/flutter_app/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/flutter_app/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter_app/ios/Runner/Base.lproj/Main.storyboard b/flutter_app/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..cd31a81 --- /dev/null +++ b/flutter_app/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/flutter_app/ios/Runner/Info.plist b/flutter_app/ios/Runner/Info.plist new file mode 100644 index 0000000..1f9a4d8 --- /dev/null +++ b/flutter_app/ios/Runner/Info.plist @@ -0,0 +1,108 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + 视野 + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + observer + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + wx0000000000000000 + + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + alipay0000000000 + + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSApplicationQueriesSchemes + + weixin + weixinULAPI + weixinURLParamsAPI + alipay + alipays + + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsArbitraryLoadsInWebContent + + + NSCameraUsageDescription + 需要使用相机进行野生动物实时识别 + NSLocalNetworkUsageDescription + 需要通过本地网络连接服务器进行账号验证和支付 + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/flutter_app/ios/Runner/Runner-Bridging-Header.h b/flutter_app/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/flutter_app/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/flutter_app/ios/Runner/Runner.entitlements b/flutter_app/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..6631ffa --- /dev/null +++ b/flutter_app/ios/Runner/Runner.entitlements @@ -0,0 +1,6 @@ + + + + + + diff --git a/flutter_app/ios/Runner/SceneDelegate.swift b/flutter_app/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/flutter_app/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/flutter_app/ios/RunnerTests/RunnerTests.swift b/flutter_app/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/flutter_app/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/flutter_app/lib/app.dart b/flutter_app/lib/app.dart new file mode 100644 index 0000000..0a1b306 --- /dev/null +++ b/flutter_app/lib/app.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'auth/auth_screen.dart'; +import 'auth/session_store.dart'; +import 'camera/camera_screen.dart'; +import 'container.dart'; +import 'home/home_screen.dart'; +import 'payment/paywall_screen.dart'; + +class ObserverApp extends StatelessWidget { + final AppContainer container; + + const ObserverApp({super.key, required this.container}); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + Provider.value(value: container), + Provider.value(value: container.sessionStore), + ChangeNotifierProvider.value(value: container.authViewModel), + ChangeNotifierProvider.value(value: container.homeViewModel), + ChangeNotifierProvider.value(value: container.paywallViewModel), + ], + child: MaterialApp( + title: '视野', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.green), + ), + initialRoute: '/gate', + routes: { + '/gate': (_) => const StartupGate(), + '/login': (_) => const AuthScreen(), + '/home': (_) => const HomeScreen(), + '/paywall': (_) => const PaywallScreen(), + '/camera': (_) => const CameraScreen(), + }, + ), + ); + } +} + +/// 启动门卫:无登录 token → 登录页;已登录 → 主界面(到期状态由主界面展示) +class StartupGate extends StatefulWidget { + const StartupGate({super.key}); + + @override + State createState() => _StartupGateState(); +} + +class _StartupGateState extends State { + @override + void initState() { + super.initState(); + _check(); + } + + Future _check() async { + final session = context.read(); + final token = await session.readToken(); + if (!mounted) return; + Navigator.of(context) + .pushReplacementNamed(token == null ? '/login' : '/home'); + } + + @override + Widget build(BuildContext context) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } +} diff --git a/flutter_app/lib/auth/auth_screen.dart b/flutter_app/lib/auth/auth_screen.dart new file mode 100644 index 0000000..0f868ba --- /dev/null +++ b/flutter_app/lib/auth/auth_screen.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +import 'auth_view_model.dart'; + +/// 登录/注册页:手机号 + 密码;注册成功后自动登录。 +class AuthScreen extends StatefulWidget { + const AuthScreen({super.key}); + + @override + State createState() => _AuthScreenState(); +} + +class _AuthScreenState extends State { + final _phoneCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + bool _obscure = true; + + @override + void dispose() { + _phoneCtrl.dispose(); + _passwordCtrl.dispose(); + super.dispose(); + } + + static final _phoneRe = RegExp(r'^1[3-9]\d{9}$'); + + Future _submit() async { + final vm = context.read(); + final phone = _phoneCtrl.text.trim(); + final password = _passwordCtrl.text; + if (!_phoneRe.hasMatch(phone)) { + vm.showError('请输入正确的 11 位手机号'); + return; + } + if (password.length < 6) { + vm.showError('密码至少 6 位'); + return; + } + final ok = await vm.submit(phone: phone, password: password); + if (ok && mounted) { + Navigator.of(context).pushReplacementNamed('/home'); + } + } + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + final isLogin = vm.mode == AuthMode.login; + + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon(Icons.pets, size: 64, color: Colors.green), + const SizedBox(height: 12), + const Text( + '视野', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + '野生动物实时识别', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade600), + ), + const SizedBox(height: 32), + TextField( + controller: _phoneCtrl, + keyboardType: TextInputType.phone, + maxLength: 11, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + labelText: '手机号', + prefixIcon: Icon(Icons.phone_android), + border: OutlineInputBorder(), + counterText: '', + ), + ), + const SizedBox(height: 16), + TextField( + controller: _passwordCtrl, + obscureText: _obscure, + maxLength: 64, + decoration: InputDecoration( + labelText: '密码', + prefixIcon: const Icon(Icons.lock_outline), + border: const OutlineInputBorder(), + counterText: '', + suffixIcon: IconButton( + icon: Icon(_obscure ? Icons.visibility_off : Icons.visibility), + onPressed: () => setState(() => _obscure = !_obscure), + ), + ), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: vm.loading ? null : _submit, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + child: vm.loading + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(isLogin ? '登录' : '注册并登录'), + ), + const SizedBox(height: 12), + TextButton( + onPressed: vm.loading ? null : vm.switchMode, + child: Text(isLogin ? '没有账号?去注册' : '已有账号?去登录'), + ), + if (vm.error != null) ...[ + const SizedBox(height: 12), + Text( + vm.error!, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/flutter_app/lib/auth/auth_view_model.dart b/flutter_app/lib/auth/auth_view_model.dart new file mode 100644 index 0000000..8b52862 --- /dev/null +++ b/flutter_app/lib/auth/auth_view_model.dart @@ -0,0 +1,57 @@ +import 'package:flutter/foundation.dart'; + +import '../payment/order_api.dart'; +import 'session_store.dart'; + +enum AuthMode { login, register } + +class AuthViewModel extends ChangeNotifier { + final OrderApi orderApi; + final SessionStore sessionStore; + + AuthMode _mode = AuthMode.login; + bool _loading = false; + String? _error; + + AuthMode get mode => _mode; + bool get loading => _loading; + String? get error => _error; + + AuthViewModel({required this.orderApi, required this.sessionStore}); + + void switchMode() { + _mode = _mode == AuthMode.login ? AuthMode.register : AuthMode.login; + _error = null; + notifyListeners(); + } + + /// 本地输入校验失败提示(不进网络请求) + void showError(String message) { + _error = message; + notifyListeners(); + } + + /// 登录或注册;成功返回 true(调用方负责跳转主界面) + Future submit({required String phone, required String password}) async { + _loading = true; + _error = null; + notifyListeners(); + try { + if (_mode == AuthMode.login) { + final token = await orderApi.login(phone: phone, password: password); + await sessionStore.save(phone, token); + } else { + await orderApi.register(phone: phone, password: password); + final token = await orderApi.login(phone: phone, password: password); + await sessionStore.save(phone, token); + } + return true; + } on OrderApiException catch (e) { + _error = e.message; + return false; + } finally { + _loading = false; + notifyListeners(); + } + } +} diff --git a/flutter_app/lib/auth/session_store.dart b/flutter_app/lib/auth/session_store.dart new file mode 100644 index 0000000..9c025ef --- /dev/null +++ b/flutter_app/lib/auth/session_store.dart @@ -0,0 +1,36 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// 登录会话持久化:token + 手机号存 secure storage。 +/// 启动时读取判断是否已登录;登出/401 时清除回登录页。 +class SessionStore { + static const _storage = FlutterSecureStorage(); + static const _tokenKey = 'auth_token'; + static const _phoneKey = 'auth_phone'; + + static String? _cachedToken; + static String? _cachedPhone; + + Future readToken() async { + if (_cachedToken != null) return _cachedToken; + return _cachedToken = await _storage.read(key: _tokenKey); + } + + Future readPhone() async { + if (_cachedPhone != null) return _cachedPhone; + return _cachedPhone = await _storage.read(key: _phoneKey); + } + + Future save(String phone, String token) async { + _cachedPhone = phone; + _cachedToken = token; + await _storage.write(key: _phoneKey, value: phone); + await _storage.write(key: _tokenKey, value: token); + } + + Future clear() async { + _cachedPhone = null; + _cachedToken = null; + await _storage.delete(key: _phoneKey); + await _storage.delete(key: _tokenKey); + } +} diff --git a/flutter_app/lib/camera/app_camera_controller.dart b/flutter_app/lib/camera/app_camera_controller.dart new file mode 100644 index 0000000..2adadf3 --- /dev/null +++ b/flutter_app/lib/camera/app_camera_controller.dart @@ -0,0 +1,92 @@ +import 'package:camera/camera.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'frame_analyzer.dart'; + +/// camera 插件封装:后摄图像流(对应 Kotlin CameraController)。 +class AppCameraController { + final List cameras; + CameraController? controller; + + /// 图像流回调实际触发次数(诊断用,与 analyzer 帧计数区分) + int streamCallbacks = 0; + + AppCameraController._(this.cameras); + + static Future create() async { + final cameras = await availableCameras(); + if (cameras.isEmpty) return null; + return AppCameraController._(cameras); + } + + bool get isInitialized => controller?.value.isInitialized ?? false; + + CameraController get currentController => + controller ?? (throw StateError('camera not initialized')); + + /// 图像流送达时的旋转角(传感器 → 竖屏显示所需的顺时针旋转)。 + /// 与 CameraX rotationDegrees 同公式;预览本身由平台旋转,检测框 overlay + /// 用同一角度映射即可对齐。 + int get rotationDegrees { + final c = controller; + if (c == null) return 0; + final deviceDegrees = switch (c.value.deviceOrientation) { + DeviceOrientation.portraitUp => 0, + DeviceOrientation.landscapeLeft => 90, + DeviceOrientation.portraitDown => 180, + DeviceOrientation.landscapeRight => 270, + }; + final sensor = c.description.sensorOrientation; + final isFront = + c.description.lensDirection == CameraLensDirection.front; + final degrees = (isFront ? sensor + deviceDegrees : sensor - deviceDegrees) % 360; + return degrees < 0 ? degrees + 360 : degrees; + } + + Future start(FrameAnalyzer analyzer) async { + await stop(); + final desc = cameras.firstWhere( + (c) => c.lensDirection == CameraLensDirection.back, + orElse: () => cameras.first); + // iOS 用默认 bgra8888(420v 在部分 iOS 版本上视频输出静默不送帧), + // Android 用 yuv420 多平面。 + final fmt = defaultTargetPlatform == TargetPlatform.iOS + ? ImageFormatGroup.bgra8888 + : ImageFormatGroup.yuv420; + final c = CameraController(desc, ResolutionPreset.high, + enableAudio: false, imageFormatGroup: fmt); + controller = c; + await c.initialize(); + // 相机(重新)启动后重置运动/背景参考与抽帧节流,避免旧场景残留 + analyzer.reset(); + analyzer.worker?.reset(); + debugPrint('[camera] initialized, starting image stream'); + try { + await c.startImageStream((image) { + streamCallbacks++; + try { + analyzer.analyze(image, rotationDegrees); + } catch (e, st) { + debugPrint('[camera] analyze error: $e\n$st'); + analyzer.recordStreamError('analyze: $e'); + } + }); + debugPrint('[camera] startImageStream ok'); + } catch (e, st) { + debugPrint('[camera] startImageStream FAILED: $e\n$st'); + analyzer.recordStreamError('startImageStream: $e'); + rethrow; + } + } + + Future stop() async { + final c = controller; + if (c == null) return; + controller = null; + try { + await c.stopImageStream(); + } catch (_) {} + await c.dispose(); + } +} diff --git a/flutter_app/lib/camera/camera_screen.dart b/flutter_app/lib/camera/camera_screen.dart new file mode 100644 index 0000000..59acee1 --- /dev/null +++ b/flutter_app/lib/camera/camera_screen.dart @@ -0,0 +1,389 @@ +import 'dart:ui' show PlatformDispatcher; + +import 'package:camera/camera.dart'; +import 'package:flutter/foundation.dart' show defaultTargetPlatform; +import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +import '../detection/detector_worker.dart'; +import '../reminder/reminder.dart'; +import 'app_camera_controller.dart'; +import 'camera_view_model.dart'; +import 'detection_overlay.dart'; +import 'frame_analyzer.dart'; + +/// 主界面:相机预览 + 检测框 overlay + 顶栏(返回/切换摄像头) +class CameraScreen extends StatefulWidget { + const CameraScreen({super.key}); + + @override + State createState() => _CameraScreenState(); +} + +class _CameraScreenState extends State { + CameraViewModel? _viewModel; + FrameAnalyzer? _analyzer; + AppCameraController? _cameraController; + bool _initFailed = false; + bool _permissionGranted = false; + String? _globalError; + String? _initError; + + @override + void initState() { + super.initState(); + final oldPlatform = PlatformDispatcher.instance.onError; + PlatformDispatcher.instance.onError = (error, stack) { + setState(() => _globalError = 'Platform: $error'); + return oldPlatform?.call(error, stack) ?? false; + }; + WidgetsBinding.instance.addPostFrameCallback((_) => _init()); + // 相机页常亮:野外观察时保持屏幕不熄(离开页面时关闭) + WakelockPlus.enable(); + } + + Future _init() async { + final granted = await Permission.camera.request().isGranted; + if (!mounted) return; + setState(() => _permissionGranted = granted); + if (!granted) return; + + // 模型加载/推理在后台 isolate,不阻塞 UI;worker 为 null 时仅预览并提示 + final worker = await DetectorWorker.create(); + final viewModel = CameraViewModel(reminder: Reminder()); + viewModel.setModelReady(worker != null); + final analyzer = FrameAnalyzer(worker: worker, viewModel: viewModel); + if (!mounted) { + analyzer.dispose(); + viewModel.dispose(); + return; + } + setState(() { + _viewModel = viewModel; + _analyzer = analyzer; + }); + + await _startCamera(); + } + + Future _startCamera() async { + final analyzer = _analyzer; + if (analyzer == null) return; + try { + final controller = await AppCameraController.create(); + if (controller == null) { + setState(() { + _initFailed = true; + _initError = '未找到可用摄像头'; + }); + return; + } + await controller.start(analyzer); + if (!mounted) { + controller.stop(); + return; + } + setState(() { + _cameraController = controller; + _initFailed = false; + _initError = null; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _initFailed = true; + _initError = '$e'; + }); + } + } + + @override + void dispose() { + WakelockPlus.disable(); + _cameraController?.stop(); + _analyzer?.dispose(); + _viewModel?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final vm = _viewModel; + final camera = _cameraController; + + return Scaffold( + backgroundColor: Colors.black, + body: Stack( + fit: StackFit.expand, + children: [ + if (!_permissionGranted) + _PermissionGuide(onRequest: () => _init()) + else if (vm != null && (camera?.isInitialized ?? false)) + // 预览 + 检测框同几何:overlay 作为 CameraPreview 的 child, + // 与纹理共享同一 Stack/尺寸,避免比例或裁剪导致的位置偏移 + ListenableBuilder( + listenable: vm, + builder: (context, _) => _ZoomablePreview( + controller: camera!.currentController, + imageWidthPx: vm.state.imageWidthPx, + imageHeightPx: vm.state.imageHeightPx, + overlay: DetectionOverlay( + results: vm.state.results, + // iOS 纹理不旋转显示(_wrapInRotatedBox 仅 Android), + // 显示方向 = buffer 原样 = 检测方向,旋转必须为 0; + // Android 纹理被 RotatedBox 旋转,需用插件报告的 rotation。 + rotation: defaultTargetPlatform == TargetPlatform.iOS + ? 0 + : vm.state.rotation, + imageWidthPx: vm.state.imageWidthPx, + imageHeightPx: vm.state.imageHeightPx, + ), + ), + ) + else if (camera?.isInitialized ?? false) + _ZoomablePreview(controller: camera!.currentController) + else + const Center( + child: Text('相机启动中…', style: TextStyle(color: Colors.white70)), + ), + + // 帧级动态层(横幅/诊断行)单独订阅 viewModel,避免整屏重建 + if (vm != null) + ListenableBuilder( + listenable: vm, + builder: (context, _) => _buildDiagnosticsLayer(camera), + ), + + if (_initFailed) + Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('相机初始化失败', style: TextStyle(color: Colors.white)), + if (_initError != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _initError!, + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.redAccent, fontSize: 11), + ), + ), + TextButton( + onPressed: () { + setState(() => _initFailed = false); + _startCamera(); + }, + child: const Text('重试'), + ), + ], + ), + ), + ), + + Positioned( + top: MediaQuery.of(context).padding.top + 8, + left: 0, + right: 0, + child: _CameraTopBar( + onClose: () => Navigator.of(context).pop(), + ), + ), + ], + ), + ); + } + + Widget _buildDiagnosticsLayer(AppCameraController? camera) { + final vm = _viewModel!; + return Stack( + fit: StackFit.expand, + children: [ + // 模型未加载时仅显示相机预览,不做检测标注(横幅置于顶栏下方,避免与底部诊断行重叠) + if (!vm.state.modelReady) + Positioned( + left: 16, + right: 16, + top: MediaQuery.of(context).padding.top + 56, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '识别模型加载失败:${DetectorWorker.lastLoadError ?? '未知原因'}\n最后步骤:${DetectorWorker.lastLog ?? '-'}', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.orange, fontSize: 14), + ), + ), + ), + + Positioned( + left: 8, + right: 8, + bottom: MediaQuery.of(context).padding.bottom + 8, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + '模型:${vm.state.modelReady ? '已加载' : '未加载'} 帧:${vm.state.framesReceived} 流:${camera?.streamCallbacks ?? 0} 推理:${vm.state.debugDetectCalls}次 异常:${vm.state.debugDetectErrors}次 处理:${vm.state.debugLastMs}ms 最高分:${(vm.state.debugHighestScore * 100).toStringAsFixed(1)}% 图:${vm.state.imageWidthPx}x${vm.state.imageHeightPx} 旋:${vm.state.rotation}', + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + if (camera != null) + Text( + 'streaming:${camera.currentController.value.isStreamingImages} ' + 'camErr:${camera.currentController.value.errorDescription ?? '无'}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.cyanAccent, fontSize: 11), + ), + if (vm.state.debugLastError != null) + Text( + vm.state.debugLastError!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.redAccent, fontSize: 11), + ), + if (_globalError != null) + Text( + _globalError!, + maxLines: 3, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.redAccent, fontSize: 11), + ), + ], + ), + ), + ], + ); + } +} + +/// 双指捏合缩放预览;overlay 与纹理同几何(CameraPreview child) +class _ZoomablePreview extends StatefulWidget { + final CameraController controller; + + /// 检测框 overlay(随帧更新,作为 CameraPreview 的 child 与纹理同区域) + final Widget? overlay; + + /// 当前帧图像尺寸(用于按 buffer 比例约束预览,保证无拉伸变形) + final int imageWidthPx; + final int imageHeightPx; + + const _ZoomablePreview({ + required this.controller, + this.overlay, + this.imageWidthPx = 0, + this.imageHeightPx = 0, + }); + + @override + State<_ZoomablePreview> createState() => _ZoomablePreviewState(); +} + +class _ZoomablePreviewState extends State<_ZoomablePreview> { + double _minZoom = 1.0; + double _maxZoom = 1.0; + double _currentZoom = 1.0; + double _gestureStartZoom = 1.0; + + @override + void initState() { + super.initState(); + widget.controller.getMinZoomLevel().then((v) { + if (mounted) setState(() => _minZoom = v); + }); + widget.controller.getMaxZoomLevel().then((v) { + if (mounted) setState(() => _maxZoom = v); + }); + } + + @override + Widget build(BuildContext context) { + final preview = GestureDetector( + onScaleStart: (_) => _gestureStartZoom = _currentZoom, + onScaleUpdate: (d) { + final target = + (_gestureStartZoom * d.scale).clamp(_minZoom, _maxZoom); + if ((target - _currentZoom).abs() < 0.01) return; + _currentZoom = target; + widget.controller.setZoomLevel(target); + }, + child: CameraPreview(widget.controller, child: widget.overlay), + ); + + final w = widget.imageWidthPx.toDouble(); + final h = widget.imageHeightPx.toDouble(); + if (w <= 0 || h <= 0) return preview; + // 按 buffer 比例约束显示区域:纹理与 overlay 同区域等比显示(无变形) + return Center( + child: AspectRatio(aspectRatio: w / h, child: preview), + ); + } +} + +class _CameraTopBar extends StatelessWidget { + final VoidCallback onClose; + + const _CameraTopBar({ + required this.onClose, + }); + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black54, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + tooltip: '返回', + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: onClose, + ), + ], + ), + ); + } +} + +class _PermissionGuide extends StatelessWidget { + final VoidCallback onRequest; + + const _PermissionGuide({required this.onRequest}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + '需要相机权限才能进行实时识别', + style: TextStyle(color: Colors.white), + ), + const SizedBox(height: 16), + FilledButton(onPressed: onRequest, child: const Text('授权相机')), + ], + ), + ); + } +} diff --git a/flutter_app/lib/camera/camera_view_model.dart b/flutter_app/lib/camera/camera_view_model.dart new file mode 100644 index 0000000..bae2886 --- /dev/null +++ b/flutter_app/lib/camera/camera_view_model.dart @@ -0,0 +1,238 @@ +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; + +import '../detection/detection_result.dart'; +import '../detection/motion_aggregator.dart'; +import '../detection/tflite_detector.dart'; +import '../reminder/reminder.dart'; + +@immutable +class CameraUiState { + final bool modelReady; + final List results; + final int rotation; + final int imageWidthPx; + final int imageHeightPx; + final double debugHighestScore; + final int debugDetectCalls; + final int debugDetectErrors; + final String? debugLastError; + final int framesReceived; + final int debugLastMs; + + const CameraUiState({ + this.modelReady = false, + this.results = const [], + this.rotation = 90, + this.imageWidthPx = 0, + this.imageHeightPx = 0, + this.debugHighestScore = 0, + this.debugDetectCalls = 0, + this.debugDetectErrors = 0, + this.debugLastError, + this.framesReceived = 0, + this.debugLastMs = 0, + }); +} + +/// 检测结果置信度分级与轨迹确认。 +/// +/// - [lowConf](模型阈值 0.10):低于此分的框在检测阶段已丢弃。 +/// - [highConf](0.35):高于此分直接确认显示;真实野鸡多为 0.1~0.2, +/// 高于 0.35 视为强证据。 +/// - 0.10~0.35 之间:需要多帧稳定([confirmFrames] 帧)或 活动证据 +/// (运动区域/背景新出现区域重叠)才确认显示。 +class CameraViewModel extends ChangeNotifier { + static const int maxTracks = 30; + static const double motionBoost = 0.15; + static const double lowConf = TfliteDetector.minScore; + static const double highConf = 0.35; + static const int confirmFrames = 3; + static const double associateRadius = 0.12; + static const int displayAgeMs = 500; + static const int forgetMs = 2000; + + /// 推理后台 isolate 是否就绪(由相机页创建 worker 后设置) + bool modelReady = false; + + final Reminder reminder; + + CameraUiState _state; + CameraUiState get state => _state; + + final Map _tracks = {}; + int _nextTrackId = 0; + + CameraViewModel({required this.reminder}) : _state = const CameraUiState(); + + void setModelReady(bool ready) { + if (modelReady == ready) return; + modelReady = ready; + _state = CameraUiState(modelReady: ready); + notifyListeners(); + } + + /// 帧分析回调(分析流调用) + void onFramesAnalyzed( + List results, + int rotation, + int imageWidthPx, + int imageHeightPx, + List motionRegions, + List noveltyRegions, { + int detectCalls = 0, + int detectErrors = 0, + String? lastError, + int framesReceived = 0, + int lastProcessMs = 0, + }) { + final now = DateTime.now().millisecondsSinceEpoch; + _associate(results, motionRegions, noveltyRegions, now); + + final visible = []; + for (final t in _tracks.values) { + if (now - t.firstSeenMs < displayAgeMs) continue; + if (now - t.lastSeenMs > forgetMs) continue; + if (!_shouldDisplay(t, motionRegions, noveltyRegions)) continue; + var r = t.result; + // 低分确认目标 + 活动证据 → 分数提升,便于视觉区分 + if (t.confirmed && + r.score < highConf && + _hasActivity(r, motionRegions, noveltyRegions)) { + r = r.copyWith(score: (r.score + motionBoost).clamp(0.0, 1.0)); + } + visible.add(r.copyWith(confirmed: t.confirmed)); + } + + // 提醒:仅新确认的野鸡轨迹(确认瞬间触发一次,10s 同类冷却在 Reminder 内) + for (final t in _tracks.values) { + if (t.label != 'pheasant' || !t.confirmed || t.reminded) continue; + final age = now - t.firstSeenMs; + if (age >= displayAgeMs && age <= displayAgeMs + 1600 && + now - t.lastSeenMs <= 300) { + t.reminded = true; + reminder.onDetected(t.label); + } + } + + var highest = 0.0; + for (final r in results) { + if (r.score > highest) highest = r.score; + } + _state = CameraUiState( + modelReady: modelReady, + results: visible, + rotation: rotation, + imageWidthPx: imageWidthPx, + imageHeightPx: imageHeightPx, + debugHighestScore: highest, + debugDetectCalls: detectCalls, + debugDetectErrors: detectErrors, + debugLastError: lastError, + framesReceived: framesReceived, + debugLastMs: lastProcessMs, + ); + notifyListeners(); + } + + /// 检测框 → 轨迹关联:按中心距离就近匹配(同标签优先,跨标签收紧距离), + /// 未匹配则新建候选轨迹。 + void _associate( + List results, + List motionRegions, + List noveltyRegions, + int now) { + final matched = {}; + for (final r in results) { + if (!_plausible(r)) continue; + _Track? best; + var bestD = associateRadius; + for (final t in _tracks.values) { + if (matched.contains(t.id)) continue; + final d = _centerDist(t.result, r); + // 同标签宽松匹配;跨标签(野鸡↔疑似 抖动)收紧到 60% + final limit = t.label == r.label ? bestD : associateRadius * 0.6; + if (d < limit) { + bestD = d; + best = t; + } + } + if (best != null) { + matched.add(best.id); + best.update(r, now); + best.seenCount++; + if (best.seenCount >= confirmFrames || r.score >= highConf || + _hasActivity(r, motionRegions, noveltyRegions)) { + best.confirmed = true; + } + } else { + final t = _Track(_nextTrackId++, now, r); + t.seenCount = 1; + t.confirmed = r.score >= highConf || + _hasActivity(r, motionRegions, noveltyRegions); + _tracks[t.id] = t; + } + } + _tracks.removeWhere( + (id, t) => !matched.contains(id) && now - t.lastSeenMs > forgetMs); + } + + /// 显示判定(按类别策略): + /// - 疑似(生境预警):设计意图是常驻静态预警,始终显示(渲染侧弱化) + /// - 野鸡:确认轨迹直接显示;未确认的只有在高分或活动证据时才显示 + bool _shouldDisplay(_Track t, List motionRegions, + List noveltyRegions) { + if (t.label == 'suspect') return true; + if (t.confirmed) return true; + return t.result.score >= highConf || + _hasActivity(t.result, motionRegions, noveltyRegions); + } + + /// 活动证据:与运动区域或背景新出现区域重叠 + bool _hasActivity(DetectionResult r, List motionRegions, + List noveltyRegions) => + motionRegions.any((m) => MotionAggregator.centerInRegion(r, m)) || + noveltyRegions.any((m) => MotionAggregator.centerInRegion(r, m)); + + /// 物理合理性过滤:宽高比与相对尺寸(野鸡 20-100px@720 量级,参照标注脚本) + bool _plausible(DetectionResult r) { + final h = r.height; + final w = r.width; + if (w <= 0 || h <= 0) return false; + final aspect = w / h; + if (aspect < 0.3 || aspect > 3.0) return false; + if (r.label == 'suspect') return h >= 0.01 && h <= 0.5; + return h >= 0.01 && h <= 0.3; + } + + double _centerDist(DetectionResult a, DetectionResult b) => + math.sqrt(math.pow(a.centerX - b.centerX, 2) + + math.pow(a.centerY - b.centerY, 2)); + + @override + void dispose() { + reminder.release(); + super.dispose(); + } +} + +class _Track { + final int id; + final String label; + int firstSeenMs; + int lastSeenMs; + int seenCount = 0; + bool confirmed = false; + bool reminded = false; + DetectionResult result; + + _Track(this.id, this.firstSeenMs, this.result) + : lastSeenMs = firstSeenMs, + label = result.label; + + void update(DetectionResult r, int now) { + lastSeenMs = now; + result = r; + } +} diff --git a/flutter_app/lib/camera/detection_overlay.dart b/flutter_app/lib/camera/detection_overlay.dart new file mode 100644 index 0000000..888f925 --- /dev/null +++ b/flutter_app/lib/camera/detection_overlay.dart @@ -0,0 +1,148 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../detection/coordinate_mapper.dart'; +import '../detection/detection_result.dart'; + +/// 检测框绘制分级: +/// - 野鸡 confirmed:红色实线 3px(强证据) +/// - 野鸡 candidate:红色虚线 2px 半透明(待确认,弱提示) +/// - 疑似(生境预警):黄色虚线 2px 半透明(常驻静态预警,弱化渲染) +/// 标签附带距离估计(针孔模型 焦距px×参考体型/框高px)。 +class DetectionOverlay extends StatelessWidget { + final List results; + final int rotation; + final int imageWidthPx; + final int imageHeightPx; + + const DetectionOverlay({ + super.key, + required this.results, + required this.rotation, + required this.imageWidthPx, + required this.imageHeightPx, + }); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: CustomPaint( + painter: _OverlayPainter(results, rotation, imageWidthPx, imageHeightPx), + child: const SizedBox.expand(), + ), + ); + } +} + +class _OverlayPainter extends CustomPainter { + final List results; + final int rotation; + final int imageWidthPx; + final int imageHeightPx; + + _OverlayPainter(this.results, this.rotation, this.imageWidthPx, + this.imageHeightPx); + + static const _colors = { + 'pheasant': Color(0xFFE53935), + 'suspect': Color(0xFFFDD835), + }; + static const _labels = {'pheasant': '野鸡', 'suspect': '疑似'}; + + /// 参考体型(米):野鸡身高 / 植被高度(参照旧 DistanceEstimator) + static const _refSizeM = {'pheasant': 0.45, 'suspect': 0.50}; + + /// iPhone 13 主摄在 1280 高预览下的估算焦距 px(5.1mm / 5.30mm 传感器), + /// 单目误差 ±30%,仅作参考 + static const double focalPx = 1230; + static const double maxDistanceM = 120; + + @override + void paint(Canvas canvas, Size size) { + for (final r in results) { + final rect = CoordinateMapper.mapToView( + r.left, + r.top, + r.right, + r.bottom, + rotation, + imageWidthPx, + imageHeightPx, + size.width, + size.height, + ); + final color = _colors[r.label] ?? Colors.white; + final isSuspect = r.label == 'suspect'; + final confirmed = r.confirmed && !isSuspect; + final paint = Paint() + ..color = color.withValues(alpha: confirmed ? 1.0 : 0.55) + ..style = PaintingStyle.stroke + ..strokeWidth = confirmed ? 3 : 2 + ..isAntiAlias = true; + final box = Rect.fromLTRB(rect.left, rect.top, rect.right, rect.bottom); + if (confirmed) { + canvas.drawRect(box, paint); + } else { + _drawDashedRect(canvas, box, paint); + } + + // 标签:框上方,含距离 + final dist = _distanceLabel(r); + final text = + '${_labels[r.label] ?? r.label} ${(r.score * 100).toInt()}%$dist'; + final textPainter = TextPainter( + text: TextSpan( + text: text, + style: TextStyle( + color: color.withValues(alpha: confirmed ? 1.0 : 0.8), + fontSize: 14, + fontWeight: FontWeight.w600, + shadows: const [Shadow(color: Colors.black, blurRadius: 3)], + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + final top = math.max(0.0, rect.top - 22); + final left = math.max(0.0, rect.left); + textPainter.paint(canvas, Offset(left + 4, top)); + } + } + + String _distanceLabel(DetectionResult r) { + final refH = _refSizeM[r.label]; + if (refH == null) return ''; + final hPx = r.height * imageHeightPx; + if (hPx < 8) return ''; + final m = focalPx * refH / hPx; + if (m > maxDistanceM) return ''; + return ' ≈${m.round()}m'; + } + + void _drawDashedRect(Canvas canvas, Rect r, Paint paint, + {double dash = 10, double gap = 6}) { + void dashLine(Offset a, Offset b) { + final total = (b - a).distance; + if (total <= 0) return; + final dir = (b - a) / total; + var d = 0.0; + while (d < total) { + final e = math.min(d + dash, total); + canvas.drawLine(a + dir * d, a + dir * e, paint); + d += dash + gap; + } + } + + dashLine(r.topLeft, r.topRight); + dashLine(r.topRight, r.bottomRight); + dashLine(r.bottomRight, r.bottomLeft); + dashLine(r.bottomLeft, r.topLeft); + } + + @override + bool shouldRepaint(_OverlayPainter oldDelegate) => + oldDelegate.results != results || + oldDelegate.rotation != rotation || + oldDelegate.imageWidthPx != imageWidthPx || + oldDelegate.imageHeightPx != imageHeightPx; +} diff --git a/flutter_app/lib/camera/frame_analyzer.dart b/flutter_app/lib/camera/frame_analyzer.dart new file mode 100644 index 0000000..2608afa --- /dev/null +++ b/flutter_app/lib/camera/frame_analyzer.dart @@ -0,0 +1,97 @@ +import 'package:camera/camera.dart'; + +import '../detection/detection_result.dart'; +import '../detection/detector_worker.dart'; +import 'camera_view_model.dart'; + +/// 抽帧节流 + 后台推理(对应 Kotlin FrameAnalyzer)。 +/// 推理在后台 isolate(DetectorWorker)执行,主 isolate 只投递帧与收结果。 +class FrameAnalyzer { + /// 连续检测:100ms 一帧 + int intervalMs = 100; + + /// null = 模型加载失败,仅预览不分析 + final DetectorWorker? worker; + final CameraViewModel viewModel; + + int _lastDetectMs = 0; + + /// 诊断计数:推理调用/异常次数 + int detectCalls = 0; + int detectErrors = 0; + String? lastError; + + /// 最近一次完整处理(预处理+推理+运动检测)耗时 ms(worker 侧) + int lastProcessMs = 0; + + /// 图像流回调是否到达(诊断用) + int framesReceived = 0; + + FrameAnalyzer({required this.worker, required this.viewModel}) { + worker?.onResult = _onResult; + worker?.onError = _onError; + } + + void recordStreamError(String msg) { + lastError = msg; + detectErrors++; + } + + void _onResult( + List results, + List motion, + List novelty, + int rotation, + int width, + int height, + int processMs) { + detectCalls++; + lastProcessMs = processMs; + viewModel.onFramesAnalyzed( + results, + rotation, + width, + height, + motion, + novelty, + detectCalls: detectCalls, + detectErrors: detectErrors, + lastError: lastError, + framesReceived: framesReceived, + lastProcessMs: lastProcessMs, + ); + } + + void _onError(String msg) { + detectErrors++; + lastError = msg; + viewModel.onFramesAnalyzed( + const [], + 90, + 0, + 0, + const [], + const [], + detectCalls: detectCalls, + detectErrors: detectErrors, + lastError: lastError, + framesReceived: framesReceived, + lastProcessMs: lastProcessMs, + ); + } + + void analyze(CameraImage image, int rotationDegrees) { + framesReceived++; + final w = worker; + if (w == null) return; + final now = DateTime.now().millisecondsSinceEpoch; + if (now - _lastDetectMs < intervalMs) return; + _lastDetectMs = now; + if (w.busy) return; // 上一帧未返回则丢帧,避免在途积压 + w.analyze(image, rotationDegrees); + } + + void reset() => _lastDetectMs = 0; + + void dispose() => worker?.dispose(); +} diff --git a/flutter_app/lib/camera/motion_detector.dart b/flutter_app/lib/camera/motion_detector.dart new file mode 100644 index 0000000..e01c965 --- /dev/null +++ b/flutter_app/lib/camera/motion_detector.dart @@ -0,0 +1,53 @@ +import 'dart:typed_data'; + +import '../detection/detection_result.dart'; +import '../detection/motion_aggregator.dart'; + +/// 轻量运动检测:相邻帧 Y 通道差分 + 分块聚合。 +/// 小尺寸工作(约 128x128 内),在分析流中串行调用。 +/// 相机大幅移动时(全屏帧差)自动忽略本帧,避免误报。 +class MotionDetector { + final int maxWidth; + final int maxHeight; + + List? _prevGray; + + MotionDetector({this.maxWidth = 128, this.maxHeight = 128}); + + /// 后台 isolate 用原始数据接口(不依赖 CameraImage)。 + List detectMotionRaw( + Uint8List yPlane, int yStride, int width, int height) { + final w = width, h = height; + final scale = maxWidth / w < maxHeight / h ? maxWidth / w : maxHeight / h; + final tw = (w * scale).toInt().clamp(1, maxWidth); + final th = (h * scale).toInt().clamp(1, maxHeight); + if (tw == 0 || th == 0) return const []; + + // 取 Y 平面缩放灰度(最近邻下采样到 128x128 内) + final y = yPlane; + final gray = List.filled(tw * th, 0); + for (var oy = 0; oy < th; oy++) { + final sy = (oy / scale).toInt().clamp(0, h - 1); + for (var ox = 0; ox < tw; ox++) { + final sx = (ox / scale).toInt().clamp(0, w - 1); + gray[oy * tw + ox] = y[sy * yStride + sx]; + } + } + + final prev = _prevGray; + _prevGray = List.of(gray); + if (prev == null || prev.length != gray.length) return const []; + + final diff = MotionAggregator.diffMask(gray, prev); + final motionTotal = diff.fold(0, (a, b) => a + b); + // 全屏大差异 → 相机移动/大范围变化,忽略本帧 + if (motionTotal > tw * th / 2) return const []; + if (motionTotal < 12) return const []; + return MotionAggregator.aggregate(diff, tw, th); + } + + /// 相机切换后重置参考帧,避免旧帧误差 + void reset() { + _prevGray = null; + } +} diff --git a/flutter_app/lib/config/app_config.dart b/flutter_app/lib/config/app_config.dart new file mode 100644 index 0000000..7160cf1 --- /dev/null +++ b/flutter_app/lib/config/app_config.dart @@ -0,0 +1,22 @@ +/// 全局配置占位:接入真实支付前需替换以下值。 +class AppConfig { + /// 后端服务器地址(订单创建/授权查询/套餐价格) + /// 默认 Android 模拟器 10.0.2.2;iOS 模拟器构建时传 + /// --dart-define=API_BASE_URL=http://127.0.0.1:8080 + static const String apiBaseUrl = + String.fromEnvironment('API_BASE_URL', defaultValue: 'http://10.0.2.2:8080'); + + /// 微信开放平台 AppID(需在微信开放平台注册包名+签名) + static const String wechatAppId = 'wx0000000000000000'; + static const String wechatUniversalLink = + 'https://YOUR_DOMAIN.com/wechat/'; + + /// 支付宝开放平台 AppID + static const String alipayAppId = '2020000000000000'; + static const String alipayUniversalLink = + 'https://YOUR_DOMAIN.com/alipay/'; + + /// iOS URL Scheme(微信/支付宝拉起回调,需与 Info.plist 一致) + static const String wechatUrlScheme = 'wx0000000000000000'; + static const String alipayUrlScheme = 'alipay0000000000'; +} diff --git a/flutter_app/lib/container.dart b/flutter_app/lib/container.dart new file mode 100644 index 0000000..57d5a3a --- /dev/null +++ b/flutter_app/lib/container.dart @@ -0,0 +1,35 @@ +import 'auth/auth_view_model.dart'; +import 'auth/session_store.dart'; +import 'home/home_view_model.dart'; +import 'payment/license_service.dart'; +import 'payment/models.dart'; +import 'payment/order_api.dart'; +import 'payment/paywall_view_model.dart'; +import 'payment/payment_service.dart'; + +/// 手动依赖注入容器(对应原 Kotlin AppContainer) +class AppContainer { + late final SessionStore sessionStore; + late final OrderApi orderApi; + late final LicenseService licenseService; + late final AuthViewModel authViewModel; + late final HomeViewModel homeViewModel; + late final PaywallViewModel paywallViewModel; + + AppContainer() { + sessionStore = SessionStore(); + orderApi = OrderApi(sessionStore: sessionStore); + licenseService = + LicenseService(orderApi: orderApi, sessionStore: sessionStore); + authViewModel = AuthViewModel(orderApi: orderApi, sessionStore: sessionStore); + homeViewModel = HomeViewModel(licenseService: licenseService); + paywallViewModel = PaywallViewModel( + orderApi: orderApi, + licenseService: licenseService, + services: { + PayChannel.wechat: WechatPayService(orderApi: orderApi), + PayChannel.alipay: AlipayService(orderApi: orderApi), + }, + ); + } +} diff --git a/flutter_app/lib/detection/background_model.dart b/flutter_app/lib/detection/background_model.dart new file mode 100644 index 0000000..1f58648 --- /dev/null +++ b/flutter_app/lib/detection/background_model.dart @@ -0,0 +1,94 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'detection_result.dart'; +import 'motion_aggregator.dart'; + +/// 静态场景背景建模:运行均值 + 方差,帧差高于自适应阈值的像素记为"新出现", +/// 分块聚合为新颖区域(novelty)。 +/// +/// 固定机位下,常驻物体(键盘/石头/文字)永远属于背景、不产生新颖区域; +/// 走进画面的目标(野鸡移动/新出现)才会触发。比相邻帧差分更强的证据: +/// 风吹草动是持续的背景更新,不会长期标记为新颖。 +class BackgroundModel { + final int maxWidth; + final int maxHeight; + + static const double learnRate = 0.05; + static const double kSigma = 2.5; + static const int minDiff = 15; + static const int minPixels = 12; + + Float32List? _mean; + Float32List? _var; + int _tw = 0; + + BackgroundModel({this.maxWidth = 128, this.maxHeight = 128}); + + /// 后台 isolate 用原始数据接口(与 MotionDetector 同源:直接取 planes[0])。 + List updateRaw( + Uint8List yPlane, int yStride, int width, int height) { + final scale = + maxWidth / width < maxHeight / height ? maxWidth / width : maxHeight / height; + final tw = (width * scale).toInt().clamp(1, maxWidth); + final th = (height * scale).toInt().clamp(1, maxHeight); + if (tw == 0 || th == 0) return const []; + + final gray = Float32List(tw * th); + for (var oy = 0; oy < th; oy++) { + final sy = (oy / scale).toInt().clamp(0, height - 1); + final idx = oy * tw; + for (var ox = 0; ox < tw; ox++) { + final sx = (ox / scale).toInt().clamp(0, width - 1); + gray[idx + ox] = yPlane[sy * yStride + sx].toDouble(); + } + } + return update(gray, tw, th); + } + + List update(Float32List gray, int tw, int th) { + final n = gray.length; + final mean = _mean; + final variance = _var; + if (mean == null || variance == null || mean.length != n || _tw != tw) { + _mean = Float32List.fromList(gray); + _var = Float32List(n); + _tw = tw; + return const []; + } + + final diff = Uint8List(n); + var fgCount = 0; + for (var i = 0; i < n; i++) { + final g = gray[i]; + final m = mean[i]; + final d = (g - m).abs(); + if (d > kSigma * math.sqrt(variance[i]) + minDiff) { + diff[i] = 1; + fgCount++; + // 前景像素不更新背景,避免把移动目标吸收进背景 + } else { + // 静态像素缓慢吸收进背景,适应光照漂移 + final nm = m + learnRate * (g - m); + mean[i] = nm; + variance[i] = + variance[i] + learnRate * ((g - nm) * (g - nm) - variance[i]); + } + } + + // 全屏大变化 → 相机移动/场景切换,重建背景 + if (fgCount > n ~/ 2) { + _mean = null; + _var = null; + return const []; + } + if (fgCount < minPixels) return const []; + return MotionAggregator.aggregate(diff, tw, th); + } + + /// 相机切换后重置,避免旧场景背景 + void reset() { + _mean = null; + _var = null; + } +} diff --git a/flutter_app/lib/detection/coordinate_mapper.dart b/flutter_app/lib/detection/coordinate_mapper.dart new file mode 100644 index 0000000..2c3772a --- /dev/null +++ b/flutter_app/lib/detection/coordinate_mapper.dart @@ -0,0 +1,69 @@ +class ViewRect { + final double left; + final double top; + final double right; + final double bottom; + + const ViewRect(this.left, this.top, this.right, this.bottom); + + double get width => right - left; + double get height => bottom - top; + double get centerX => (left + right) / 2; + double get centerY => (top + bottom) / 2; +} + +/// 模型归一化坐标 → 预览视图坐标(含传感器旋转与 FIT_CENTER 裁剪)。 +class CoordinateMapper { + static ViewRect mapToView( + double normLeft, + double normTop, + double normRight, + double normBottom, + int rotation, + int imageW, + int imageH, + double viewW, + double viewH, + ) { + // 1) 旋转校正:图像方向 → 竖屏视图方向(归一化坐标) + late final double x0, y0, x1, y1; + switch (rotation) { + case 90: + x0 = 1 - normBottom; + y0 = normLeft; + x1 = 1 - normTop; + y1 = normRight; + case 180: + x0 = 1 - normRight; + y0 = 1 - normBottom; + x1 = 1 - normLeft; + y1 = 1 - normTop; + case 270: + x0 = normTop; + y0 = 1 - normRight; + x1 = normBottom; + y1 = 1 - normLeft; + default: + x0 = normLeft; + y0 = normTop; + x1 = normRight; + y1 = normBottom; + } + // 2) 旋转后图像在竖屏方向上的尺寸 + final portrait = rotation == 90 || rotation == 270; + final portW = portrait ? imageH : imageW; + final portH = portrait ? imageW : imageH; + // 3) FIT_CENTER 缩放与居中偏移 + final scale = viewW / portW < viewH / portH + ? viewW / portW + : viewH / portH; + final offsetX = (viewW - portW * scale) / 2; + final offsetY = (viewH - portH * scale) / 2; + return ViewRect( + x0 * portW * scale + offsetX, + y0 * portH * scale + offsetY, + x1 * portW * scale + offsetX, + y1 * portH * scale + offsetY, + ); + } +} diff --git a/flutter_app/lib/detection/detection_result.dart b/flutter_app/lib/detection/detection_result.dart new file mode 100644 index 0000000..8c5c5bb --- /dev/null +++ b/flutter_app/lib/detection/detection_result.dart @@ -0,0 +1,56 @@ +class DetectionResult { + final String label; + final double score; + final double left; + final double top; + final double right; + final double bottom; + + /// 轨迹已确认(多帧稳定/高分/活动确认),false = 候选,渲染为虚线 + final bool confirmed; + + const DetectionResult({ + required this.label, + required this.score, + required this.left, + required this.top, + required this.right, + required this.bottom, + this.confirmed = true, + }); + + double get width => right - left; + double get height => bottom - top; + double get centerX => (left + right) / 2; + double get centerY => (top + bottom) / 2; + + DetectionResult copyWith({ + double? score, + double? left, + double? top, + double? right, + double? bottom, + bool? confirmed, + }) => + DetectionResult( + label: label, + score: score ?? this.score, + left: left ?? this.left, + top: top ?? this.top, + right: right ?? this.right, + bottom: bottom ?? this.bottom, + confirmed: confirmed ?? this.confirmed, + ); +} + +class MotionRegion { + final double left; + final double top; + final double right; + final double bottom; + + const MotionRegion(this.left, this.top, this.right, this.bottom); + + double get centerX => (left + right) / 2; + double get centerY => (top + bottom) / 2; +} diff --git a/flutter_app/lib/detection/detector_worker.dart b/flutter_app/lib/detection/detector_worker.dart new file mode 100644 index 0000000..720c6ab --- /dev/null +++ b/flutter_app/lib/detection/detector_worker.dart @@ -0,0 +1,265 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:camera/camera.dart'; +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:flutter/services.dart' show rootBundle; + +import '../camera/motion_detector.dart'; +import 'background_model.dart'; +import 'detection_result.dart'; +import 'tflite_detector.dart'; +import 'visual_prior.dart'; + +/// 推理工作单元:模型加载与检测全部在后台 isolate 执行, +/// 主 isolate 只投递帧数据、接收结果,UI 不被推理阻塞(iOS 真机卡顿根因)。 +class DetectorWorker { + static const String modelAsset = 'assets/model.tflite'; + static const String labelsAsset = 'assets/labels.txt'; + + final Isolate _isolate; + final ReceivePort _responses; + + final _controlPort = Completer(); + final _ready = Completer(); + + SendPort? _port; + + /// 在途帧数(主 isolate 侧计数,用于丢帧) + int _inFlight = 0; + bool _dead = false; + + /// 结果回调:结果 / 运动区域 / 新颖区域 / 旋转角 / 图宽 / 图高 / 处理耗时 ms + void Function(List, List, List, + int, int, int, int)? onResult; + + /// 单帧处理异常回调(不影响相机流) + void Function(String)? onError; + + /// 最近一次创建失败的诊断原因(UI 展示用) + static String? lastLoadError; + + /// worker 最近上报的执行步骤(诊断用) + static String? lastLog; + + DetectorWorker._(this._isolate, this._responses) { + _responses.listen(_onMessage, onDone: () { + _dead = true; + if (!_ready.isCompleted) { + _ready.completeError(StateError('推理进程异常退出')); + } + onError?.call('推理进程异常退出'); + }); + } + + /// 读取模型资产并启动后台推理 isolate;加载失败返回 null(App 降级为仅预览)。 + static Future create() async { + try { + final data = await rootBundle.load(modelAsset); + final modelBytes = + data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); + final labels = (await rootBundle.loadString(labelsAsset)) + .split('\n') + .where((l) => l.trim().isNotEmpty) + .toList(); + + final responses = ReceivePort(); + final isolate = await Isolate.spawn(_workerMain, responses.sendPort); + final worker = DetectorWorker._(isolate, responses); + + final port = await worker._controlPort.future + .timeout(const Duration(seconds: 10), + onTimeout: () => throw TimeoutException('worker port timeout')); + worker._port = port; + port.send(['load', modelBytes, labels]); + await worker._ready.future + .timeout(const Duration(seconds: 20), onTimeout: () { + throw TimeoutException('model load timeout'); + }); + return worker; + } catch (e) { + lastLoadError = e.toString(); + debugPrint('[DetectorWorker] create failed: $e'); + return null; + } + } + + /// 是否忙(上一帧尚未返回):忙则丢帧,避免在途积压 + bool get busy => _inFlight > 0; + + void analyze(CameraImage image, int rotationDegrees) { + final port = _port; + if (port == null || _dead) return; + _inFlight++; + port.send([ + 'frame', + [ + image.planes.map((p) => p.bytes).toList(), + image.planes.map((p) => p.bytesPerRow).toList(), + image.width, + image.height, + image.format.group == ImageFormatGroup.bgra8888, + rotationDegrees, + ], + ]); + } + + void _onMessage(dynamic msg) { + final list = msg as List; + switch (list[0] as String) { + case 'port': + _controlPort.complete(list[1] as SendPort); + break; + case 'ready': + _ready.complete(); + break; + case 'load-error': + _ready.completeError(StateError( + list.length > 1 ? list[1] as String : 'model load failed')); + break; + case 'result': + _inFlight--; + final dets = (list[4] as List).map((d) { + final v = d as List; + return DetectionResult( + label: v[0] as String, + score: v[1] as double, + left: v[2] as double, + top: v[3] as double, + right: v[4] as double, + bottom: v[5] as double, + ); + }).toList(); + final motion = (list[5] as List) + .map((m) => m as List) + .map((v) => MotionRegion( + v[0] as double, v[1] as double, v[2] as double, v[3] as double)) + .toList(); + final novelty = (list[6] as List) + .map((m) => m as List) + .map((v) => MotionRegion( + v[0] as double, v[1] as double, v[2] as double, v[3] as double)) + .toList(); + onResult?.call(dets, motion, novelty, list[1] as int, list[2] as int, + list[3] as int, list[7] as int); + break; + case 'log': + lastLog = list[1] as String; + debugPrint('[DetectorWorker] $lastLog'); + break; + case 'error': + _inFlight--; + onError?.call(list[1] as String); + } + } + + /// 相机切换/场景变化后重置运动与背景参考 + void reset() { + final port = _port; + if (port == null || _dead) return; + port.send(['reset']); + } + + void dispose() { + _dead = true; + _isolate.kill(priority: Isolate.immediate); + _responses.close(); + } +} + +/// 后台 isolate 入口:串行处理 load / frame / reset 命令。 +/// 所有回发必须走 [mainPort](主 isolate 的端口);control 是 worker 自己的 +/// 收件箱,往 control.sendPort 发消息等于发给自己,主 isolate 永远收不到。 +Future _workerMain(SendPort mainPort) async { + final control = ReceivePort(); + mainPort.send(['port', control.sendPort]); + mainPort.send(['log', 'worker-start']); + + TfliteDetector? detector; + MotionDetector? motion; + BackgroundModel? background; + await for (final msg in control) { + try { + final list = msg as List; + switch (list[0] as String) { + case 'load': + mainPort.send(['log', 'load-received']); + try { + detector = await TfliteDetector.fromBuffer( + list[1] as Uint8List, (list[2] as List).cast()); + if (detector == null) { + mainPort.send(['load-error', 'fromBuffer 返回 null']); + } else { + mainPort.send(['log', 'fromBuffer-ok']); + motion = MotionDetector(); + background = BackgroundModel(); + mainPort.send(['ready']); + } + } catch (e) { + mainPort.send(['load-error', '$e']); + } + break; + case 'frame': + final d = detector; + final m = motion; + final b = background; + if (d == null || m == null || b == null) break; + final frame = list[1] as List; + final planes = (frame[0] as List).cast(); + final strides = (frame[1] as List).cast(); + final width = frame[2] as int; + final height = frame[3] as int; + final isBgra = frame[4] as bool; + final rotation = frame[5] as int; + + final sw = Stopwatch()..start(); + var results = d.detectRaw( + planes: planes, + strides: strides, + width: width, + height: height, + isBgra: isBgra, + ); + // 低分野鸡框过视觉先验(颜色/位置),减少户外误报 + results = VisualPrior.filter( + results, + planes: planes, + strides: strides, + width: width, + height: height, + isBgra: isBgra, + ); + final motionRegions = m.detectMotionRaw( + planes[0], strides[0], width, height); + final noveltyRegions = + b.updateRaw(planes[0], strides[0], width, height); + sw.stop(); + + mainPort.send([ + 'result', + rotation, + width, + height, + results + .map((r) => + [r.label, r.score, r.left, r.top, r.right, r.bottom]) + .toList(), + motionRegions + .map((mr) => [mr.left, mr.top, mr.right, mr.bottom]) + .toList(), + noveltyRegions + .map((mr) => [mr.left, mr.top, mr.right, mr.bottom]) + .toList(), + sw.elapsedMilliseconds, + ]); + break; + case 'reset': + motion?.reset(); + background?.reset(); + } + } catch (e) { + mainPort.send(['error', '$e']); + } + } +} diff --git a/flutter_app/lib/detection/motion_aggregator.dart b/flutter_app/lib/detection/motion_aggregator.dart new file mode 100644 index 0000000..a427a01 --- /dev/null +++ b/flutter_app/lib/detection/motion_aggregator.dart @@ -0,0 +1,96 @@ +import 'dart:math' as math; + +import 'detection_result.dart'; + +/// 帧差运动聚合:每像素 0/1 差分掩码 → 8x8 分块统计 → 连通块聚合为运动区域。 +class MotionAggregator { + static const int blockGrid = 8; + static const double blockActiveRatio = 0.30; + static const int maxRegions = 3; + static const int diffThreshold = 25; + + static List aggregate(List diff, int width, int height) { + final bw = width ~/ blockGrid; + final bh = height ~/ blockGrid; + if (bw == 0 || bh == 0) return const []; + + final active = List.filled(blockGrid * blockGrid, false); + for (var by = 0; by < blockGrid; by++) { + for (var bx = 0; bx < blockGrid; bx++) { + final blockW = bx == blockGrid - 1 ? width - bx * bw : bw; + final blockH = by == blockGrid - 1 ? height - by * bh : bh; + var motion = 0; + for (var y = by * bh; y < by * bh + blockH; y++) { + var idx = y * width + bx * bw; + for (var x = 0; x < blockW; x++) { + motion += diff[idx + x]; + } + idx += width; + } + active[by * blockGrid + bx] = + motion > blockW * blockH * blockActiveRatio; + } + } + + final regions = []; + final visited = List.filled(active.length, false); + for (var i = 0; i < active.length; i++) { + if (!active[i] || visited[i]) continue; + var minX = blockGrid, minY = blockGrid, maxX = -1, maxY = -1; + final stack = [i]; + visited[i] = true; + while (stack.isNotEmpty) { + final cur = stack.removeLast(); + final bx = cur % blockGrid; + final by = cur ~/ blockGrid; + if (bx < minX) minX = bx; + if (bx > maxX) maxX = bx; + if (by < minY) minY = by; + if (by > maxY) maxY = by; + for (final nb in neighbors(cur)) { + if (active[nb] && !visited[nb]) { + visited[nb] = true; + stack.add(nb); + } + } + } + if (maxX - minX > 3 || maxY - minY > 3) continue; // 全屏噪声过滤 + regions.add(MotionRegion( + minX * bw / width, + minY * bh / height, + math.min((maxX + 1) * bw, width) / width, + math.min((maxY + 1) * bh, height) / height, + )); + if (regions.length >= maxRegions) break; + } + return regions; + } + + static List neighbors(int i) { + final bx = i % blockGrid; + final by = i ~/ blockGrid; + final list = []; + if (bx > 0) list.add(i - 1); + if (bx < blockGrid - 1) list.add(i + 1); + if (by > 0) list.add(i - blockGrid); + if (by < blockGrid - 1) list.add(i + blockGrid); + return list; + } + + /// 检测框中心是否落在运动区域内(用于置信度提升判定) + static bool centerInRegion(DetectionResult box, MotionRegion region) => + box.centerX >= region.left && + box.centerX <= region.right && + box.centerY >= region.top && + box.centerY <= region.bottom; + + /// 帧差掩码:|g - prev| > threshold → 1 + static List diffMask(List gray, List prev, + [int threshold = diffThreshold]) { + final diff = List.filled(gray.length, 0); + for (var i = 0; i < gray.length; i++) { + diff[i] = (gray[i] - prev[i]).abs() > threshold ? 1 : 0; + } + return diff; + } +} diff --git a/flutter_app/lib/detection/nms.dart b/flutter_app/lib/detection/nms.dart new file mode 100644 index 0000000..d5e33dc --- /dev/null +++ b/flutter_app/lib/detection/nms.dart @@ -0,0 +1,21 @@ +import 'detection_result.dart'; + +double iou(DetectionResult a, DetectionResult b) { + final x0 = a.left > b.left ? a.left : b.left; + final y0 = a.top > b.top ? a.top : b.top; + final x1 = a.right < b.right ? a.right : b.right; + final y1 = a.bottom < b.bottom ? a.bottom : b.bottom; + if (x1 <= x0 || y1 <= y0) return 0; + final inter = (x1 - x0) * (y1 - y0); + final union = a.width * a.height + b.width * b.height - inter; + return union <= 0 ? 0 : inter / union; +} + +List nms(List boxes, double iouThreshold) { + final sorted = [...boxes]..sort((a, b) => b.score.compareTo(a.score)); + final kept = []; + for (final b in sorted) { + if (!kept.any((k) => iou(b, k) > iouThreshold)) kept.add(b); + } + return kept; +} diff --git a/flutter_app/lib/detection/tflite_detector.dart b/flutter_app/lib/detection/tflite_detector.dart new file mode 100644 index 0000000..bf42f00 --- /dev/null +++ b/flutter_app/lib/detection/tflite_detector.dart @@ -0,0 +1,312 @@ +import 'dart:typed_data'; + +import 'package:tflite_flutter/tflite_flutter.dart'; + +import 'detection_result.dart'; +import 'nms.dart'; + +/// YOLOv8n 端侧推理实现(对应 Kotlin TFLiteDetector)。 +/// 模型输出布局(ultralytics litert 导出):[1, 4 + nc, anchors], +/// cx/cy/w/h 已归一化,类别得分已过 sigmoid;按 out[dim][anchor] 索引。 +/// 输入为 NCHW [1, 3, 704, 704](litert 导出保留 torch 布局)。 +class TfliteDetector { + static const int inputSize = 704; + // 野鸡数据置信度普遍偏低(0.1~0.2 量级),保留低分池供运动检测提升 + static const double minScore = 0.10; + static const double iouThreshold = 0.45; + static const int maxDetections = 20; + static const String modelAsset = 'assets/model.tflite'; + static const String labelsAsset = 'assets/labels.txt'; + + final Interpreter _interpreter; + final List _labels; + final int _numClasses; + final int _numAnchors; + + final Float32List _input = + Float32List(1 * inputSize * inputSize * 3); + + /// 输出按模型形状 [1, 4+nc, anchors] 的嵌套 List 组织, + /// run() 要求输出对象形状与模型完全一致(扁平 List 会被拒)。 + final List>> _output; + + TfliteDetector._(this._interpreter, this._labels, this._numClasses, + this._numAnchors, this._output); + + /// 模型缺失或加载失败返回 null(App 降级为仅预览)。 + /// 在后台 isolate 内调用(模型字节由主 isolate 读取后传入)。 + static Future fromBuffer( + Uint8List bytes, List labels) async { + try { + final interpreter = Interpreter.fromBuffer( + bytes, + options: InterpreterOptions()..threads = 4, + ); + return TfliteDetector._fromModel(interpreter, labels); + } catch (_) { + return null; + } + } + + /// 输出布局 [1, 4+nc, anchors] 取自模型本身,类别数不与 labels 文件长度耦合。 + factory TfliteDetector._fromModel( + Interpreter interpreter, List labels) { + final shape = interpreter.getOutputTensor(0).shape; + final numClasses = + shape.length >= 3 && shape[1] > 4 ? shape[1] - 4 : labels.length; + final numAnchors = shape.length >= 3 && shape[2] > 0 ? shape[2] : 2100; + final output = List.generate( + 1, + (_) => List.generate( + numClasses + 4, + (_) => List.filled(numAnchors, 0), + ), + ); + return TfliteDetector._( + interpreter, labels, numClasses, numAnchors, output); + } + + /// 原始数据接口(后台 isolate 用,不依赖 CameraImage)。 + /// 输出坐标统一反算为原图归一化空间(与 MotionDetector 一致), + /// 否则 CENTER_CROP 裁剪偏移会让检测框系统性偏移。 + List detectRaw({ + required List planes, + required List strides, + required int width, + required int height, + required bool isBgra, + }) { + preprocess( + planes: planes, + strides: strides, + width: width, + height: height, + isBgra: isBgra); + // 传原始字节视图而非 Float32List:tflite_flutter 会对非 ByteBuffer/Uint8List + // 输入调用 resizeInputTensor(1 维 [1486848]),使 node 0 TRANSPOSE prepare 失败 + _interpreter.run(_input.buffer.asUint8List(), _output); + final dets = postprocess(); + // 反算与 preprocess 的 scale/dx/dy 公式一致(704 输入空间 → 原图归一化) + final scale = inputSize / width < inputSize / height + ? inputSize / width + : inputSize / height; + final dx = (inputSize - width * scale) / 2; + final dy = (inputSize - height * scale) / 2; + if (dx == 0 && dy == 0) return dets; + return dets + .map((r) => r.copyWith( + left: (r.left * inputSize - dx) / (width * scale), + right: (r.right * inputSize - dx) / (width * scale), + top: (r.top * inputSize - dy) / (height * scale), + bottom: (r.bottom * inputSize - dy) / (height * scale), + )) + .toList(); + } + + /// 按像素格式分派:iOS bgra8888 单平面 / Android yuv420 多平面。 + void preprocess({ + required List planes, + required List strides, + required int width, + required int height, + required bool isBgra, + }) { + if (isBgra) { + _preprocessBgra(planes[0], strides[0], width, height); + } else { + _preprocessYuv(planes, strides, width, height); + } + } + + /// BGRA8888 单平面(iOS):每像素 4 字节 [b,g,r,a],双线性采样, + /// letterbox(等比缩到长边 704,短边黑边补 0,与 YOLO 训练一致)。 + void _preprocessBgra(Uint8List src, int stride, int srcW, int srcH) { + final plane = inputSize * inputSize; + final scale = inputSize / srcW < inputSize / srcH + ? inputSize / srcW + : inputSize / srcH; + final dx = (inputSize - srcW * scale) / 2; + final dy = (inputSize - srcH * scale) / 2; + + for (var oy = 0; oy < inputSize; oy++) { + final syf = (oy - dy) / scale; + if (syf < 0 || syf >= srcH) { + for (var ox = 0; ox < inputSize; ox++) { + final p = oy * inputSize + ox; + _input[p] = 0; + _input[p + plane] = 0; + _input[p + 2 * plane] = 0; + } + continue; + } + for (var ox = 0; ox < inputSize; ox++) { + final p = oy * inputSize + ox; + final sxf = (ox - dx) / scale; + if (sxf < 0 || sxf >= srcW) { + _input[p] = 0; + _input[p + plane] = 0; + _input[p + 2 * plane] = 0; + continue; + } + final x0 = sxf.floor(), y0 = syf.floor(); + final x1 = x0 < srcW - 1 ? x0 + 1 : x0; + final y1 = y0 < srcH - 1 ? y0 + 1 : y0; + final fx = sxf - x0, fy = syf - y0; + + // BGRA 字节序:+0 B、+1 G、+2 R、+3 A + final i00 = y0 * stride + x0 * 4; + final i10 = y0 * stride + x1 * 4; + final i01 = y1 * stride + x0 * 4; + final i11 = y1 * stride + x1 * 4; + final r00 = src[i00 + 2].toDouble(); + final g00 = src[i00 + 1].toDouble(); + final b00 = src[i00].toDouble(); + final r10 = src[i10 + 2].toDouble(); + final g10 = src[i10 + 1].toDouble(); + final b10 = src[i10].toDouble(); + final r01 = src[i01 + 2].toDouble(); + final g01 = src[i01 + 1].toDouble(); + final b01 = src[i01].toDouble(); + final r11 = src[i11 + 2].toDouble(); + final g11 = src[i11 + 1].toDouble(); + final b11 = src[i11].toDouble(); + + _input[p] = _bl(r00, r10, r01, r11, fx, fy) / 255.0; + _input[p + plane] = _bl(g00, g10, g01, g11, fx, fy) / 255.0; + _input[p + 2 * plane] = _bl(b00, b10, b01, b11, fx, fy) / 255.0; + } + } + } + + /// letterbox 缩放 + YUV → RGB 归一化 0~1(NCHW),双线性采样。 + /// 兼容 NV12(iOS 双平面,UV 交错)与 I420(Android 三平面)。 + void _preprocessYuv( + List planes, List strides, int srcW, int srcH) { + final plane = inputSize * inputSize; + final y = planes[0]; + final nv12 = planes.length == 2; + final uv = nv12 ? planes[1] : null; + final u = nv12 ? null : planes[1]; + final v = nv12 ? null : planes[2]; + final yStride = strides[0]; + final uvStride = strides[1]; + + // U/V 平面采样(nv12:偶位 U 奇位 V;i420:三平面分离) + double uAt(int x, int y) => nv12 + ? uv![y * uvStride + x * 2] - 128.0 + : u![y * uvStride + x] - 128.0; + double vAt(int x, int y) => nv12 + ? uv![y * uvStride + x * 2 + 1] - 128.0 + : v![y * uvStride + x] - 128.0; + + final scale = inputSize / srcW < inputSize / srcH + ? inputSize / srcW + : inputSize / srcH; + final dx = (inputSize - srcW * scale) / 2; + final dy = (inputSize - srcH * scale) / 2; + + for (var oy = 0; oy < inputSize; oy++) { + final syf = (oy - dy) / scale; + if (syf < 0 || syf >= srcH) { + for (var ox = 0; ox < inputSize; ox++) { + final p = oy * inputSize + ox; + _input[p] = 0; + _input[p + plane] = 0; + _input[p + 2 * plane] = 0; + } + continue; + } + for (var ox = 0; ox < inputSize; ox++) { + final p = oy * inputSize + ox; + final sxf = (ox - dx) / scale; + if (sxf < 0 || sxf >= srcW) { + _input[p] = 0; + _input[p + plane] = 0; + _input[p + 2 * plane] = 0; + continue; + } + final x0 = sxf.floor(), y0 = syf.floor(); + final x1 = x0 < srcW - 1 ? x0 + 1 : x0; + final y1 = y0 < srcH - 1 ? y0 + 1 : y0; + final fx = sxf - x0, fy = syf - y0; + + // Y 双线性 + final y00 = y[y0 * yStride + x0].toDouble(); + final y10 = y[y0 * yStride + x1].toDouble(); + final y01 = y[y1 * yStride + x0].toDouble(); + final y11 = y[y1 * yStride + x1].toDouble(); + final yy = _bl(y00, y10, y01, y11, fx, fy); + + // U/V 双线性(4:2:0 半分辨率,按像素坐标定位后除 2) + final maxUx = srcW ~/ 2 - 1; + final maxUy = srcH ~/ 2 - 1; + final ux0 = (x0 ~/ 2).clamp(0, maxUx).toInt(); + final uy0 = (y0 ~/ 2).clamp(0, maxUy).toInt(); + final ux1 = (x1 ~/ 2).clamp(0, maxUx).toInt(); + final uy1 = (y1 ~/ 2).clamp(0, maxUy).toInt(); + final u00 = uAt(ux0, uy0); + final u10 = uAt(ux1, uy0); + final u01 = uAt(ux0, uy1); + final u11 = uAt(ux1, uy1); + final uu = _bl(u00, u10, u01, u11, fx, fy); + + final v00 = vAt(ux0, uy0); + final v10 = vAt(ux1, uy0); + final v01 = vAt(ux0, uy1); + final v11 = vAt(ux1, uy1); + final vv = _bl(v00, v10, v01, v11, fx, fy); + + // 有限范围展开(VideoRange Y 16~235,Cb/Cr 16~240) + final yr = (yy - 16.0) * (255.0 / 219.0); + final un = uu * (255.0 / 224.0); + final vn = vv * (255.0 / 224.0); + + // NCHW:r/g/b 分平面存储 + _input[p] = (yr + 1.402 * vn) / 255.0; + _input[p + plane] = (yr - 0.344136 * un - 0.714136 * vn) / 255.0; + _input[p + 2 * plane] = (yr + 1.772 * un) / 255.0; + } + } + } + + static double _bl(double a, double b, double c, double d, double fx, + double fy) => + (1 - fx) * (1 - fy) * a + fx * (1 - fy) * b + + (1 - fx) * fy * c + fx * fy * d; + + List postprocess() { + final out = _output[0]; + final boxes = []; + for (var a = 0; a < _numAnchors; a++) { + final cx = out[0][a]; + final cy = out[1][a]; + final w = out[2][a]; + final h = out[3][a]; + var bestCls = 0; + var bestScore = 0.0; + for (var c = 0; c < _numClasses; c++) { + final s = out[4 + c][a]; + if (s > bestScore) { + bestScore = s; + bestCls = c; + } + } + final label = + bestCls < _labels.length ? _labels[bestCls] : 'unknown'; + // 低分池保留,供运动检测提升显示 + if (bestScore < minScore) continue; + boxes.add(DetectionResult( + label: label, + score: bestScore, + left: (cx - w / 2).clamp(0.0, 1.0), + top: (cy - h / 2).clamp(0.0, 1.0), + right: (cx + w / 2).clamp(0.0, 1.0), + bottom: (cy + h / 2).clamp(0.0, 1.0), + )); + } + final kept = nms(boxes, iouThreshold); + return kept.take(maxDetections).toList(); + } + + void dispose() => _interpreter.close(); +} diff --git a/flutter_app/lib/detection/visual_prior.dart b/flutter_app/lib/detection/visual_prior.dart new file mode 100644 index 0000000..66b83a1 --- /dev/null +++ b/flutter_app/lib/detection/visual_prior.dart @@ -0,0 +1,113 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'detection_result.dart'; + +/// 运行时视觉先验:对低置信度野鸡框做多线索过滤,降低户外误报。 +/// +/// 仅对 score < [maxScore](0.35)的 pheasant 框生效;高分框与 +/// suspect(生境预警)不参与过滤,避免误杀。 +/// +/// 线索: +/// - 颜色:绿色主导(草/叶)、蓝色主导(天空/水)、平坦低饱和(键盘/石头/文字) +/// - 位置:中心在画面上部 15%(天空区)——野鸡是地栖动物,不会出现在天空 +/// +/// 采样在原始 planes 上进行(后台 isolate 内,不依赖 UI 线程)。 +class VisualPrior { + static const double maxScore = 0.35; + static const double skyTopRatio = 0.15; + + // 颜色判定阈值(与 tflite_detector 的 YUV 有限范围展开一致) + static const double greenDiff = 20; + static const double blueDiff = 10; + static const double flatRange = 10; + + // 采样点中满足条件的比例超过即拒绝 + static const double greenRatio = 0.5; + static const double blueRatio = 0.4; + static const double flatRatio = 0.6; + + static List filter( + List results, { + required List planes, + required List strides, + required int width, + required int height, + required bool isBgra, + }) { + if (results.isEmpty || width <= 0 || height <= 0) return results; + final kept = []; + for (final r in results) { + final lowConfPheasant = r.label == 'pheasant' && r.score < maxScore; + if (lowConfPheasant && _reject(r, planes, strides, width, height, isBgra)) { + continue; + } + kept.add(r); + } + return kept; + } + + static bool _reject(DetectionResult r, List planes, + List strides, int width, int height, bool isBgra) { + // 位置线索:detectRaw 输出为图像坐标系,centerY 直接可判天空区 + if (r.centerY < skyTopRatio) return true; + + // 颜色线索:框中心 ±20% 区域 5×5 采样(小框采样点重合也没关系) + final cx = (r.centerX * width).round().clamp(0, width - 1).toInt(); + final cy = (r.centerY * height).round().clamp(0, height - 1).toInt(); + final halfW = math.max(1.0, r.width * width * 0.2); + final halfH = math.max(1.0, r.height * height * 0.2); + + var green = 0, blue = 0, flat = 0, total = 0; + for (var gy = -2; gy <= 2; gy++) { + for (var gx = -2; gx <= 2; gx++) { + final px = (cx + gx * halfW / 2).round().clamp(0, width - 1).toInt(); + final py = (cy + gy * halfH / 2).round().clamp(0, height - 1).toInt(); + final (r_, g_, b_) = _pixel(planes, strides, px, py, width, height, isBgra); + total++; + final mn = math.min(r_, math.min(g_, b_)); + final mx = math.max(r_, math.max(g_, b_)); + if (g_ - r_ > greenDiff && g_ - b_ > greenDiff) green++; + if (b_ > r_ + blueDiff) blue++; + if (mx - mn < flatRange) flat++; + } + } + if (total == 0) return false; + if (green / total > greenRatio) return true; + if (blue / total > blueRatio) return true; + if (flat / total > flatRatio) return true; + return false; + } + + /// 读取单像素 RGB(0~255)。 + /// BGRA 单平面:每像素 4 字节 [b,g,r,a]; + /// YUV:y 平面 + 4:2:0 半分辨率 U/V(NV12 交错或 I420 分离)。 + static (double, double, double) _pixel(List planes, + List strides, int x, int y, int width, int height, bool isBgra) { + if (isBgra) { + final src = planes[0]; + final i = y * strides[0] + x * 4; + return (src[i + 2].toDouble(), src[i + 1].toDouble(), src[i].toDouble()); + } + final yy = + (planes[0][y * strides[0] + x] - 16.0) * (255.0 / 219.0); + final nv12 = planes.length == 2; + final ux = (x ~/ 2).clamp(0, width ~/ 2 - 1).toInt(); + final uy = (y ~/ 2).clamp(0, height ~/ 2 - 1).toInt(); + final uvStride = strides[1]; + final un = ((nv12 + ? planes[1][uy * uvStride + ux * 2].toDouble() + : planes[1][uy * uvStride + ux].toDouble()) - + 128.0) * + (255.0 / 224.0); + final vn = ((nv12 + ? planes[1][uy * uvStride + ux * 2 + 1].toDouble() + : planes[2][uy * uvStride + ux].toDouble()) - + 128.0) * + (255.0 / 224.0); + final r = yy + 1.402 * vn; + final g = yy - 0.344136 * un - 0.714136 * vn; + final b = yy + 1.772 * un; + return (r, g, b); + } +} diff --git a/flutter_app/lib/home/home_screen.dart b/flutter_app/lib/home/home_screen.dart new file mode 100644 index 0000000..0191890 --- /dev/null +++ b/flutter_app/lib/home/home_screen.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../auth/session_store.dart'; +import 'home_view_model.dart'; + +/// 主界面:当前账号到期时间 + 搜索按钮(强制服务端校验后进相机)+ 充值入口 +class HomeScreen extends StatefulWidget { + const HomeScreen({super.key}); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().refresh(); + }); + } + + Future _onSearch() async { + final vm = context.read(); + final allowed = await vm.verifyForCamera(); + if (!mounted) return; + if (vm.sessionExpired) { + _toLogin(); + return; + } + if (!allowed) { + if (vm.error != null) { + // 网络/服务端异常:无法确认授权状态,提示错误 + _showBlocked(vm.error); + return; + } + // 服务端确认无有效授权:直接进充值页 + await _onRecharge(); + return; + } + await Navigator.of(context).pushNamed('/camera'); + if (mounted) vm.refresh(); + } + + Future _onRecharge() async { + await Navigator.of(context).pushNamed('/paywall'); + if (mounted) context.read().refresh(); + } + + Future _toLogin() async { + final container = context.read(); + await container.clear(); + if (!mounted) return; + Navigator.of(context).pushReplacementNamed('/login'); + } + + void _showBlocked(String? error) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('授权不可用'), + content: Text(error ?? '授权已过期,请充值后续费'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('取消'), + ), + FilledButton( + onPressed: () { + Navigator.of(ctx).pop(); + _onRecharge(); + }, + child: const Text('去充值'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + final license = vm.license; + final active = license?.isActive ?? false; + final statusText = vm.loading + ? '加载中…' + : (license == null || license.expiresAt == null + ? '未充值' + : (active + ? '有效至 ${_fmt(license.expiresAt!)}' + : '已过期(${_fmt(license.expiresAt!)})')); + + return Scaffold( + appBar: AppBar( + title: const Text('视野'), + centerTitle: true, + actions: [ + IconButton( + tooltip: '退出登录', + icon: const Icon(Icons.logout), + onPressed: _toLogin, + ), + ], + ), + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 24), + _StatusCard( + licenseText: statusText, + active: active, + ), + const SizedBox(height: 40), + SizedBox( + height: 88, + child: FilledButton.icon( + onPressed: vm.verifying ? null : _onSearch, + style: FilledButton.styleFrom( + backgroundColor: Colors.green, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(44), + ), + ), + icon: vm.verifying + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.visibility, size: 32), + label: Text( + vm.verifying ? '正在校验授权…' : '打开视野', + style: const TextStyle( + fontSize: 22, fontWeight: FontWeight.w600), + ), + ), + ), + const SizedBox(height: 16), + SizedBox( + height: 52, + child: OutlinedButton.icon( + onPressed: _onRecharge, + icon: const Icon(Icons.payment), + label: const Text('充值', style: TextStyle(fontSize: 16)), + ), + ), + if (vm.error != null && vm.license != null) ...[ + const SizedBox(height: 16), + Text( + vm.error!, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + ], + ], + ), + ), + ), + ); + } + + static String _fmt(DateTime t) { + String p(int n) => n.toString().padLeft(2, '0'); + return '${t.year}-${p(t.month)}-${p(t.day)} ${p(t.hour)}:${p(t.minute)}'; + } +} + +class _StatusCard extends StatelessWidget { + final String licenseText; + final bool active; + + const _StatusCard({ + required this.licenseText, + required this.active, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Icon( + active ? Icons.verified_user : Icons.error_outline, + color: active ? Colors.green : Colors.orange, + size: 36, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('当前账户', + style: TextStyle( + color: Colors.grey.shade600, fontSize: 13)), + const SizedBox(height: 4), + Text( + licenseText, + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.w600), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter_app/lib/home/home_view_model.dart b/flutter_app/lib/home/home_view_model.dart new file mode 100644 index 0000000..7ed4e76 --- /dev/null +++ b/flutter_app/lib/home/home_view_model.dart @@ -0,0 +1,67 @@ +import 'package:flutter/foundation.dart'; + +import '../payment/license_service.dart'; +import '../payment/models.dart'; +import '../payment/order_api.dart'; + +/// 主界面状态:到期时间展示 + 搜索入口强制校验 + 退出登录 +class HomeViewModel extends ChangeNotifier { + final LicenseService licenseService; + + LicenseStatus? _license; + bool _loading = true; + bool _verifying = false; + String? _error; + bool _sessionExpired = false; + + LicenseStatus? get license => _license; + bool get loading => _loading; + bool get verifying => _verifying; + String? get error => _error; + bool get sessionExpired => _sessionExpired; + + HomeViewModel({required this.licenseService}); + + /// 进入主界面/支付返回后刷新(本地缓存优先,服务端为准) + Future refresh() async { + _loading = true; + _error = null; + notifyListeners(); + try { + _license = await licenseService.check(); + } catch (_) { + // check 内部已兜底,这里仅防御 + } finally { + _loading = false; + notifyListeners(); + } + } + + /// 搜索按钮:强制服务端校验授权,active 才允许进入相机。 + /// 网络失败/会话失效一律不放行;返回值表示是否可进入。 + Future verifyForCamera() async { + _verifying = true; + _sessionExpired = false; + _error = null; + notifyListeners(); + try { + final license = await licenseService.verifyServer(); + _license = license; + return license.isActive; + } on SessionExpiredException { + _sessionExpired = true; + return false; + } on OrderApiException catch (e) { + _error = e.message; + return false; + } finally { + _verifying = false; + notifyListeners(); + } + } + + void clearSessionExpired() { + _sessionExpired = false; + notifyListeners(); + } +} diff --git a/flutter_app/lib/main.dart b/flutter_app/lib/main.dart new file mode 100644 index 0000000..1ec44d2 --- /dev/null +++ b/flutter_app/lib/main.dart @@ -0,0 +1,27 @@ +import 'package:fluwx/fluwx.dart' as fluwx; +import 'package:flutter/material.dart'; +import 'package:tobias/tobias.dart' as tobias; + +import 'app.dart'; +import 'config/app_config.dart'; +import 'container.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // 注册微信/支付宝 SDK(AppID 占位,接入真实商户后替换 app_config.dart) + try { + await fluwx.Fluwx().registerApi( + appId: AppConfig.wechatAppId, + universalLink: AppConfig.wechatUniversalLink, + ); + } catch (_) {} + try { + await tobias.Tobias().registerApp( + AppConfig.alipayAppId, + universalLink: AppConfig.alipayUniversalLink, + ); + } catch (_) {} + + runApp(ObserverApp(container: AppContainer())); +} diff --git a/flutter_app/lib/payment/license_service.dart b/flutter_app/lib/payment/license_service.dart new file mode 100644 index 0000000..da53f93 --- /dev/null +++ b/flutter_app/lib/payment/license_service.dart @@ -0,0 +1,79 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../auth/session_store.dart'; +import 'models.dart'; +import 'order_api.dart'; + +/// 授权管理:本地缓存用于主界面展示到期时间;识别入口强制服务端校验。 +/// 缓存按手机号隔离(键含账号),切换账号不会读到上一账号的到期时间。 +class LicenseService { + static const _storage = FlutterSecureStorage(); + + final OrderApi orderApi; + final SessionStore sessionStore; + + LicenseService({required this.orderApi, required this.sessionStore}); + + /// 主界面展示/启动加载:先读本账号本地缓存,有效则直接用; + /// 无效或缺失时询问服务端(网络失败按缓存兜底,未登录按过期处理)。 + Future check() async { + final phone = await sessionStore.readPhone(); + if (phone == null) return const LicenseStatus(active: false); + + final cached = await _readCache(phone); + final now = DateTime.now(); + if (cached != null && cached.expiresAt!.isAfter(now)) return cached; + + if (await sessionStore.readToken() == null) { + return const LicenseStatus(active: false); + } + try { + final remote = await orderApi.fetchLicense(); + try { + if (remote.active && remote.expiresAt != null) { + await _writeCache(phone, remote.expiresAt!); + } else if (!remote.active) { + await _clearCache(phone); + } + } catch (_) { + // 本地缓存不可用不影响授权状态展示 + } + return remote; + } on OrderApiException { + return cached ?? const LicenseStatus(active: false); + } + } + + /// 识别入口强制校验:必须走服务端且 active 才放行,失败即抛错(不进相机) + Future verifyServer() async { + if (await sessionStore.readToken() == null) { + throw const SessionExpiredException(); + } + return orderApi.fetchLicense(); + } + + /// 支付成功后立即刷新(清缓存强制走服务端,避免旧授权干扰) + Future refresh() async { + final phone = await sessionStore.readPhone(); + if (phone != null) await _clearCache(phone); + return orderApi.fetchLicense(); + } + + Future _readCache(String phone) async { + try { + final raw = await _storage.read(key: _cacheKey(phone)); + final t = raw == null ? null : DateTime.tryParse(raw); + if (t == null || !t.isAfter(DateTime.now())) return null; + return LicenseStatus(active: true, expiresAt: t); + } catch (_) { + return null; + } + } + + Future _writeCache(String phone, DateTime expiresAt) => + _storage.write(key: _cacheKey(phone), value: expiresAt.toIso8601String()); + + Future _clearCache(String phone) => _storage.delete(key: _cacheKey(phone)); + + static String _cacheKey(String phone) => 'license_expires_at:$phone'; +} diff --git a/flutter_app/lib/payment/models.dart b/flutter_app/lib/payment/models.dart new file mode 100644 index 0000000..5b9738e --- /dev/null +++ b/flutter_app/lib/payment/models.dart @@ -0,0 +1,67 @@ +/// 充值套餐(来自后端 GET /api/v1/plans,价格以服务端 config.yml 为准) +class Plan { + final String id; + final int days; + final int priceYuan; + + const Plan({ + required this.id, + required this.days, + required this.priceYuan, + }); + + /// 展示名由天数派生(接口无 label 字段) + String get label => '$days天'; + + factory Plan.fromJson(Map json) => Plan( + id: json['planId'] as String, + days: json['days'] as int, + priceYuan: (json['priceCents'] as num) ~/ 100, + ); +} + +/// 支付渠道 +enum PayChannel { wechat, alipay } + +/// 订单(由后端创建) +class Order { + final String orderId; + + /// 微信支付参数(prepay_id / partner_id / nonce_str / time_stamp / sign 等) + final Map wechatParams; + + /// 支付宝订单串(orderStr) + final String? alipayOrderStr; + + const Order({ + required this.orderId, + this.wechatParams = const {}, + this.alipayOrderStr, + }); +} + +/// 授权状态(服务端为准) +class LicenseStatus { + final bool active; + final DateTime? expiresAt; + + const LicenseStatus({required this.active, this.expiresAt}); + + bool get isActive => active && (expiresAt?.isAfter(DateTime.now()) ?? false); + + factory LicenseStatus.fromJson(Map json) => LicenseStatus( + active: json['active'] == true, + expiresAt: json['expiresAt'] != null + ? DateTime.tryParse(json['expiresAt'] as String) + : null, + ); +} + +/// 支付结果 +class PayResult { + final bool success; + final String? message; + final String? orderId; + + const PayResult({required this.success, this.message, this.orderId}); +} diff --git a/flutter_app/lib/payment/order_api.dart b/flutter_app/lib/payment/order_api.dart new file mode 100644 index 0000000..567f4fc --- /dev/null +++ b/flutter_app/lib/payment/order_api.dart @@ -0,0 +1,170 @@ +import 'dart:convert'; +import 'dart:io' show Platform; + +import 'package:cupertino_http/cupertino_http.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:http/http.dart' as http; + +import '../auth/session_store.dart'; +import '../config/app_config.dart'; +import 'models.dart'; + +/// 后端账号/支付/授权 API 客户端(契约见 docs/PaymentApi.md)。 +/// 后端未部署或请求失败时抛出 [OrderApiException];登录失效抛出 [SessionExpiredException], +/// 由 UI 层回登录页。 +class OrderApiException implements Exception { + final String message; + const OrderApiException(this.message); + + @override + String toString() => message; +} + +/// 登录已失效(后端返回 code 61):token 过期/被清,UI 应清除会话回登录页 +class SessionExpiredException extends OrderApiException { + const SessionExpiredException() : super('登录已失效,请重新登录'); +} + +class OrderApi { + final String baseUrl; + final SessionStore sessionStore; + final http.Client _client; + + // iOS 26 对 dart:io 原生 socket 访问本地网络存在拦截 bug(权限已允许仍 + // 拒绝连接),改用 NSURLSession 网络栈(CupertinoClient)绕过;非 Apple + // 平台回退 IOClient。 + OrderApi({String? baseUrl, required this.sessionStore, http.Client? client}) + : baseUrl = baseUrl ?? AppConfig.apiBaseUrl, + _client = client ?? _defaultHttpClient(); + + static http.Client _defaultHttpClient() { + if (!kIsWeb && Platform.isIOS) { + return CupertinoClient.defaultSessionConfiguration(); + } + return http.Client(); + } + + /// 注册账号;重复注册等业务错误由 [_decode] 抛出 + Future register({ + required String phone, + required String password, + }) async { + final res = await _post('/api/v1/auth/register', + jsonEncode({'phone': phone, 'password': password}), + auth: false); + _decode(res); + } + + /// 登录,成功返回 token(由调用方存入 SessionStore) + Future login({ + required String phone, + required String password, + }) async { + final res = await _post('/api/v1/auth/login', + jsonEncode({'phone': phone, 'password': password}), + auth: false); + final json = _decode(res); + return json['token'] as String; + } + + /// 创建订单,返回支付参数(微信 prepay 参数或支付宝 orderStr) + Future createOrder({ + required String planId, + required PayChannel channel, + }) async { + final body = jsonEncode({'planId': planId, 'channel': channel.name}); + final res = await _post('/api/v1/orders', body); + final json = _decode(res); + final params = (json['payParams'] as Map?)?.cast() ?? {}; + return Order( + orderId: json['orderId'] as String, + wechatParams: params, + alipayOrderStr: params['orderStr'] as String?, + ); + } + + /// 客户端支付完成后通知服务端(幂等),服务端据异步回调落授权 + Future confirmOrder(String orderId) async { + final res = + await _post('/api/v1/orders/$orderId/confirm', jsonEncode({})); + _decode(res); + } + + /// 拉取套餐价格方案(config.yml 静态定价,客户端不硬编码) + Future> fetchPlans() async { + final http.Response res; + try { + res = await _client.get( + Uri.parse('$baseUrl/api/v1/plans'), + headers: {'Accept': 'application/json', ...await _authHeaders()}, + ).timeout(const Duration(seconds: 15)); + } catch (e) { + if (e is OrderApiException) rethrow; + throw OrderApiException('网络请求失败: $e'); + } + final json = _decode(res); + return (json['list'] as List) + .map((e) => Plan.fromJson(e as Map)) + .toList(); + } + + /// 查询授权状态(服务端为准) + Future fetchLicense() async { + final http.Response res; + try { + res = await _client.get( + Uri.parse('$baseUrl/api/v1/license'), + headers: {'Accept': 'application/json', ...await _authHeaders()}, + ).timeout(const Duration(seconds: 15)); + } catch (e) { + if (e is OrderApiException) rethrow; + throw OrderApiException('网络请求失败: $e'); + } + final json = _decode(res); + return LicenseStatus.fromJson(json); + } + + Future _post(String path, String body, + {bool auth = true}) async { + try { + return await _client + .post( + Uri.parse('$baseUrl$path'), + headers: { + 'Content-Type': 'application/json', + ...auth ? await _authHeaders() : const {}, + }, + body: body, + ) + .timeout(const Duration(seconds: 15)); + } catch (e) { + if (e is OrderApiException) rethrow; + throw OrderApiException('网络请求失败: $e'); + } + } + + Future> _authHeaders() async { + final token = await sessionStore.readToken(); + if (token == null || token.isEmpty) { + throw const SessionExpiredException(); + } + return {'Authorization': 'Bearer $token'}; + } + + Map _decode(http.Response res) { + final Map json; + try { + json = jsonDecode(res.body) as Map; + } catch (_) { + throw OrderApiException('服务端响应异常 (${res.statusCode})'); + } + if (res.statusCode != 200 || json['code'] != 0) { + final message = json['message'] as String? ?? '服务端错误 (${res.statusCode})'; + if (json['code'] == 61) { + throw const SessionExpiredException(); + } + throw OrderApiException(message); + } + return json['data'] as Map; + } +} diff --git a/flutter_app/lib/payment/payment_service.dart b/flutter_app/lib/payment/payment_service.dart new file mode 100644 index 0000000..de48747 --- /dev/null +++ b/flutter_app/lib/payment/payment_service.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:fluwx/fluwx.dart' as fluwx; +import 'package:tobias/tobias.dart' as tobias; + +import '../config/app_config.dart'; +import 'models.dart'; +import 'order_api.dart'; + +/// 支付服务抽象:创建订单 + 拉起渠道支付 + 返回支付结果 +abstract class PaymentService { + /// 支付完成后会通知服务端落授权;成功与否以 [PayResult.success] 为准 + Future pay(Plan plan, PayChannel channel); +} + +/// 微信支付(fluwx 实现) +class WechatPayService implements PaymentService { + WechatPayService({required this.orderApi}); + + final OrderApi orderApi; + + @override + Future pay(Plan plan, PayChannel channel) async { + assert(channel == PayChannel.wechat); + final order = + await orderApi.createOrder(planId: plan.id, channel: channel); + final p = order.wechatParams; + + final launched = await fluwx.Fluwx().pay( + which: fluwx.Payment( + appId: AppConfig.wechatAppId, + partnerId: p['partnerId'] as String? ?? '', + prepayId: p['prepayId'] as String? ?? '', + packageValue: p['packageValue'] as String? ?? 'Sign=WXPay', + nonceStr: p['nonceStr'] as String? ?? '', + timestamp: int.tryParse('${p['timeStamp'] ?? p['timestamp']}') ?? 0, + sign: p['sign'] as String? ?? '', + ), + ); + if (!launched) { + return const PayResult( + success: false, message: '未安装微信或拉起支付失败,请稍后再试'); + } + + // 等待微信回调(errCode == 0 为成功),10s 超时 + final completer = Completer(); + final cancel = fluwx.Fluwx().addSubscriber((response) { + if (response is! fluwx.WeChatPaymentResponse) return; + completer.complete(PayResult( + success: response.isSuccessful, + message: response.isSuccessful ? null : (response.errStr ?? '微信支付未完成'), + orderId: order.orderId, + )); + }); + try { + final result = + await completer.future.timeout(const Duration(seconds: 10)); + if (result.success) await orderApi.confirmOrder(order.orderId); + return result; + } on TimeoutException { + return PayResult( + success: false, + message: '等待微信支付结果超时,请确认支付状态', + orderId: order.orderId); + } finally { + cancel.cancel(); + } + } +} + +/// 支付宝支付(tobias 实现,resultStatus 9000 为成功) +class AlipayService implements PaymentService { + AlipayService({required this.orderApi}); + + final OrderApi orderApi; + + @override + Future pay(Plan plan, PayChannel channel) async { + assert(channel == PayChannel.alipay); + final order = + await orderApi.createOrder(planId: plan.id, channel: channel); + final orderStr = order.alipayOrderStr; + if (orderStr == null) { + return const PayResult(success: false, message: '订单缺少支付参数'); + } + + final map = await tobias.Tobias().pay( + orderStr, + universalLink: AppConfig.alipayUniversalLink, + ); + final status = map['resultStatus']?.toString() ?? ''; + final ok = status == '9000'; + if (ok) await orderApi.confirmOrder(order.orderId); + return PayResult( + success: ok, + message: ok ? null : _alipayMessage(status), + orderId: order.orderId, + ); + } + + static String _alipayMessage(String status) => switch (status) { + '8000' => '支付结果确认中,请稍后查看', + '6001' => '用户取消支付', + '6002' => '网络异常,支付未完成', + '6004' => '支付结果未知,请查询订单状态', + _ => '支付宝支付未完成', + }; +} diff --git a/flutter_app/lib/payment/paywall_screen.dart b/flutter_app/lib/payment/paywall_screen.dart new file mode 100644 index 0000000..a052329 --- /dev/null +++ b/flutter_app/lib/payment/paywall_screen.dart @@ -0,0 +1,221 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'models.dart'; +import 'paywall_view_model.dart'; + +/// 付费墙:拉取套餐价格 → 选套餐 → 微信/支付宝支付 → 解锁进入相机 +class PaywallScreen extends StatefulWidget { + const PaywallScreen({super.key}); + + @override + State createState() => _PaywallScreenState(); +} + +class _PaywallScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().loadPlans(); + }); + } + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + final loading = vm.state.state == PayState.loading; + + return Scaffold( + appBar: AppBar(title: const Text('视野 · 会员'), centerTitle: true), + body: SafeArea( + child: Column( + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(24, 16, 24, 16), + child: Text( + '开通会员解锁完整功能,按自然日计费,到期自动失效', + style: TextStyle(color: Colors.grey, fontSize: 13), + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 24), + children: [ + _PlansSection(vm: vm, loading: loading), + const SizedBox(height: 24), + _PayButton( + label: '微信支付', + icon: Icons.wechat, + color: const Color(0xFF07C160), + enabled: !loading && vm.state.selectedPlan != null, + onTap: loading + ? null + : () async { + final license = await vm.pay(PayChannel.wechat); + if (license != null && context.mounted) { + _onPaid(context); + } + }, + ), + const SizedBox(height: 12), + _PayButton( + label: '支付宝支付', + icon: Icons.account_balance_wallet, + color: const Color(0xFF1677FF), + enabled: !loading && vm.state.selectedPlan != null, + onTap: loading + ? null + : () async { + final license = await vm.pay(PayChannel.alipay); + if (license != null && context.mounted) { + _onPaid(context); + } + }, + ), + if (vm.state.state == PayState.failed) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Text( + vm.state.message ?? '支付失败', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ], + ), + ), + ); + } + + /// 支付成功:返回主界面(主界面刷新展示新到期时间) + void _onPaid(BuildContext context) { + Navigator.of(context).pop(); + } +} + +/// 套餐区三态:加载中 / 失败可重试 / 套餐卡片列表 +class _PlansSection extends StatelessWidget { + final PaywallViewModel vm; + final bool loading; + + const _PlansSection({required this.vm, required this.loading}); + + @override + Widget build(BuildContext context) { + if (vm.plansLoading && vm.plans.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 48), + child: Center(child: CircularProgressIndicator()), + ); + } + if (vm.plansError != null && vm.plans.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Column( + children: [ + Text( + vm.plansError!, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () => context.read().loadPlans(), + child: const Text('重试'), + ), + ], + ), + ); + } + return Column( + children: [ + ...vm.plans.map((p) => _PlanCard( + plan: p, + selected: vm.state.selectedPlan?.id == p.id, + enabled: !loading, + onTap: () => vm.selectPlan(p), + )), + ], + ); + } +} + +class _PlanCard extends StatelessWidget { + final Plan plan; + final bool selected; + final bool enabled; + final VoidCallback onTap; + + const _PlanCard({ + required this.plan, + required this.selected, + required this.enabled, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Card( + elevation: selected ? 3 : 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: selected ? Colors.orange : Colors.grey.shade300, + width: selected ? 2 : 1, + ), + ), + child: ListTile( + onTap: enabled ? onTap : null, + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + title: Text( + plan.label, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 17), + ), + subtitle: Text('${plan.days} 天 · 自然日'), + trailing: Text( + '¥${plan.priceYuan}', + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), + ), + ); + } +} + +class _PayButton extends StatelessWidget { + final String label; + final IconData icon; + final Color color; + final bool enabled; + final VoidCallback? onTap; + + const _PayButton({ + required this.label, + required this.icon, + required this.color, + required this.enabled, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + height: 48, + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: color, + disabledBackgroundColor: Colors.grey.shade300, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + ), + onPressed: enabled ? onTap : null, + icon: Icon(icon, size: 20), + label: Text(label, style: const TextStyle(fontSize: 16)), + ), + ); + } +} diff --git a/flutter_app/lib/payment/paywall_view_model.dart b/flutter_app/lib/payment/paywall_view_model.dart new file mode 100644 index 0000000..32524cd --- /dev/null +++ b/flutter_app/lib/payment/paywall_view_model.dart @@ -0,0 +1,120 @@ +import 'package:flutter/foundation.dart'; + +import 'license_service.dart'; +import 'models.dart'; +import 'order_api.dart'; +import 'payment_service.dart'; + +enum PayState { idle, loading, success, failed } + +@immutable +class PaywallUiState { + final PayState state; + final String? message; + final Plan? selectedPlan; + + const PaywallUiState({ + this.state = PayState.idle, + this.message, + this.selectedPlan, + }); +} + +class PaywallViewModel extends ChangeNotifier { + final OrderApi orderApi; + final LicenseService licenseService; + final Map services; + + PaywallUiState _state = const PaywallUiState(); + PaywallUiState get state => _state; + + List _plans = const []; + bool _plansLoading = false; + String? _plansError; + + /// 套餐价格方案(来自后端,客户端不硬编码) + List get plans => _plans; + bool get plansLoading => _plansLoading; + String? get plansError => _plansError; + + PaywallViewModel({ + required this.orderApi, + required this.licenseService, + required this.services, + }); + + /// 进充值页时拉取套餐;已有数据或加载中跳过,失败可重试 + Future loadPlans() async { + if (_plans.isNotEmpty || _plansLoading) return; + _plansLoading = true; + _plansError = null; + notifyListeners(); + try { + _plans = await orderApi.fetchPlans(); + } on SessionExpiredException { + _plansError = '登录已失效,请重新登录后再充值'; + } on OrderApiException catch (e) { + _plansError = e.message; + } finally { + _plansLoading = false; + notifyListeners(); + } + } + + void selectPlan(Plan? plan) { + _state = PaywallUiState(selectedPlan: plan); + notifyListeners(); + } + + /// 发起支付;成功后刷新授权并通知页面进入相机 + Future pay(PayChannel channel) async { + final plan = _state.selectedPlan; + if (plan == null) return null; + final service = services[channel]; + if (service == null) return null; + + _state = PaywallUiState(state: PayState.loading, selectedPlan: plan); + notifyListeners(); + + try { + final result = await service.pay(plan, channel); + if (!result.success) { + _state = PaywallUiState( + state: PayState.failed, + message: result.message ?? '支付失败,请重试', + selectedPlan: plan, + ); + notifyListeners(); + return null; + } + final license = await licenseService.refresh(); + _state = PaywallUiState( + state: PayState.success, + selectedPlan: plan, + ); + notifyListeners(); + return license; + } on SessionExpiredException { + _state = PaywallUiState( + state: PayState.failed, + message: '登录已失效,请重新登录后再充值', + selectedPlan: plan, + ); + notifyListeners(); + return null; + } on OrderApiException catch (e) { + _state = PaywallUiState( + state: PayState.failed, + message: '支付不可用: ${e.message}', + selectedPlan: plan, + ); + notifyListeners(); + return null; + } + } + + void reset() { + _state = const PaywallUiState(); + notifyListeners(); + } +} diff --git a/flutter_app/lib/reminder/reminder.dart b/flutter_app/lib/reminder/reminder.dart new file mode 100644 index 0000000..28c6826 --- /dev/null +++ b/flutter_app/lib/reminder/reminder.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:vibration/vibration.dart'; + +/// 提醒:同类目标 10s 内只提醒一次。 +/// 震动/提示音默认开启,不提供关闭入口。 +class Reminder { + final AudioPlayer _player = AudioPlayer(); + String? _lastAlertLabel; + int _lastAlertAt = 0; + + /// 同类目标 10s 内只提醒一次 + void onDetected(String label) { + final now = DateTime.now().millisecondsSinceEpoch; + if (label == _lastAlertLabel && now - _lastAlertAt < 10000) return; + _lastAlertAt = now; + _lastAlertLabel = label; + + _vibrate(); + _playTone(); + } + + void _vibrate() => Vibration.vibrate(duration: 200); + + Future _playTone() async { + await _player.stop(); + await _player.play(AssetSource('beep.wav')); + } + + void release() { + _player.dispose(); + } +} diff --git a/flutter_app/pubspec.lock b/flutter_app/pubspec.lock new file mode 100644 index 0000000..6a61797 --- /dev/null +++ b/flutter_app/pubspec.lock @@ -0,0 +1,882 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.13.1" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "2ba4bb2944baacbdd5372ff8254a8e7feb8c10d7739545e392f5605a8f618745" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.8.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: f5ff5b15620fbab8cb0849e9636c48e2b96c3f0f71723bbbe2ad3c761b205f05 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.3.0" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "1ca553add991384ecf421b9569da850f3ab2472ffb83f6970b0416365abc51be" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.5.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: "15178b726b7cdee5364d0463c8d445630c4e0fb7d26612b73c767e7d25de9417" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.3.0" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "765f6f0e6dca55cb471c9483fc77700564b3484d19198aca4ebb5147c6c85acb" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.2.0" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: ae1e0103c865a03e273f6d13d97b93f5595eac09915729cd5e37ef96e2857319 + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.3.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: a70ae82bba2dfcb6eb03dd4815d737a2d46d33ea5a96a03f535cfcaac490e413 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.4.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + camera: + dependency: "direct main" + description: + name: camera + sha256: "4142a19a38e388d3bab444227636610ba88982e36dff4552d5191a86f65dc437" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.11.4" + camera_android_camerax: + dependency: "direct main" + description: + name: camera_android_camerax + sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.30" + camera_avfoundation: + dependency: "direct main" + description: + name: camera_avfoundation + sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.23+2" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "4524ca6eb4176b066864036ad4fe02c3e4863e63b77eadc21a5bf56824f43498" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.13.1" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "081441bd2f92b5841aa70ff4d6eddfe34078d97f9d085cc9e0f9d0102f145925" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.5+5" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.5+4" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.7" + cupertino_http: + dependency: "direct main" + description: + name: cupertino_http + sha256: "3c8c69cc1b94b9c7570d9454bf1e11fa7010b248547a0760843adc71f0c08fbe" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.15" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "11.5.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.3" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.35" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + fluwx: + dependency: "direct main" + description: + name: fluwx + sha256: d25c26a12a1fa280833105869ea255a5877695e03dc1190f0d2c027a7c989ef3 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.6.3" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.2" + http_profile: + dependency: transitive + description: + name: http_profile + sha256: "7e679e355b09aaee2ab5010915c932cce3f2d1c11c3b2dc177891687014ffa78" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.1.0" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.7" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.flutter-io.cn" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.18.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: f9c168717100ae6d9fee9ffb0be379bf1f8b26b0f6bcbd4fdddcd931993a6a72 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.19.2" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.1" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" + url: "https://pub.flutter-io.cn" + source: hosted + version: "11.4.0" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc + url: "https://pub.flutter-io.cn" + source: hosted + version: "12.1.0" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.6.1" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.1.4+1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: a5c8a97ecf5616112a5b16d4b8e9ec0e5ae90ef63ac69c0d7b8ae240be760b23 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.4.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.8" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.1.5+1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + quiver: + dependency: transitive + description: + name: quiver + sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.2" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.4.1+2" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.11" + tflite_flutter: + dependency: "direct main" + description: + name: tflite_flutter + sha256: "48e6fde2ad97162bb66a16a142f4c4698add9e8cd397ce9d1cc7451b55537ac1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.11.0" + tobias: + dependency: "direct main" + description: + name: tobias + sha256: "1cfd203bf57f8d4daa3e734d24ef97568eb5265af62d1498c32da4e69905c70f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.3.4" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.6.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" + vibration: + dependency: "direct main" + description: + name: vibration + sha256: "3b08a0579c2f9c18d5d78cb5c74f1005f731e02eeca6d72561a2e8059bf98ec3" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.0" + vibration_platform_interface: + dependency: transitive + description: + name: vibration_platform_interface + sha256: "6ffeee63547562a6fef53c05a41d4fdcae2c0595b83ef59a4813b0612cd2bc36" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.0.3" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "15.3.0" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.2" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "0618d1799f0b28bcf98255b4ee8313e6fc4d38589dc4ee5fe5840d57d1aff6da" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.12.2 <4.0.0" + flutter: ">=3.44.0" diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml new file mode 100644 index 0000000..be60f51 --- /dev/null +++ b/flutter_app/pubspec.yaml @@ -0,0 +1,50 @@ +name: observer +description: "视野 - 野生动物实时识别 (野鸡/生境), YOLOv8 + 充值付费" +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: ^3.12.2 + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + camera: ^0.11.0 + camera_android_camerax: ^0.6.6 + camera_avfoundation: ^0.9.17 + tflite_flutter: ^0.11.0 + flutter_secure_storage: ^9.2.0 + provider: ^6.1.0 + permission_handler: ^11.3.0 + vibration: ^2.0.0 + audioplayers: ^6.1.0 + fluwx: ^4.5.0 + tobias: ^5.3.0 + http: ^1.2.0 + cupertino_http: ^3.0.2 + wakelock_plus: ^1.2.0 + +# 微信/支付宝原生 SDK 配置(占位值,与 lib/config/app_config.dart 一致;接入真实支付时替换。 +# 注意:fluwx 的 universal_link 占位符会被其 pod 脚本注入 Associated Domains, +# 导致个人团队签名失败,故真实域名配置前不声明该项) +fluwx: + app_id: wx0000000000000000 + +tobias: + url_scheme: alipay0000000000 + ios: + universal_link: https://YOUR_DOMAIN.com/alipay/ + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true + assets: + - assets/model.tflite + - assets/labels.txt + - assets/beep.wav diff --git a/flutter_app/test/auth_flow_test.dart b/flutter_app/test/auth_flow_test.dart new file mode 100644 index 0000000..0460fe5 --- /dev/null +++ b/flutter_app/test/auth_flow_test.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:observer/auth/auth_screen.dart'; +import 'package:observer/auth/auth_view_model.dart'; +import 'package:observer/auth/session_store.dart'; +import 'package:observer/home/home_screen.dart'; +import 'package:observer/home/home_view_model.dart'; +import 'package:observer/payment/license_service.dart'; +import 'package:observer/payment/models.dart'; +import 'package:observer/payment/order_api.dart'; +import 'package:provider/provider.dart'; + +/// 测试环境无 secure storage 平台实现:mock 通道让读写立即返回空。 +void _mockSecureStorage() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'), + (call) async => null, + ); +} + +/// 内存版 SessionStore:不触平台通道(flutter_secure_storage 在测试环境不可用) +class FakeSession extends SessionStore { + String? token; + String? phone; + bool cleared = false; + + @override + Future readToken() async => token; + @override + Future readPhone() async => phone; + @override + Future save(String phone, String token) async { + this.phone = phone; + this.token = token; + } + + @override + Future clear() async { + token = null; + phone = null; + cleared = true; + } +} + +/// 内存版 OrderApi:登录/注册/授权全部本地返回,不触网络 +class FakeOrderApi extends OrderApi { + final LicenseStatus licenseResult; + FakeOrderApi(SessionStore s, {this.licenseResult = const LicenseStatus(active: false)}) + : super(sessionStore: s); + + @override + Future register({required String phone, required String password}) async {} + + @override + Future login({required String phone, required String password}) async => + 'fake-token'; + + @override + Future fetchLicense() async => licenseResult; +} + +Widget _app(FakeSession session, FakeOrderApi api, + {String initialRoute = '/login'}) { + final authVm = AuthViewModel(orderApi: api, sessionStore: session); + final homeVm = HomeViewModel( + licenseService: LicenseService(orderApi: api, sessionStore: session)); + return MultiProvider( + providers: [ + Provider.value(value: session), + ChangeNotifierProvider.value(value: authVm), + ChangeNotifierProvider.value(value: homeVm), + ], + child: MaterialApp( + initialRoute: initialRoute, + routes: { + '/login': (_) => const AuthScreen(), + '/home': (_) => const HomeScreen(), + '/camera': (_) => const Scaffold(body: Text('camera-stub')), + }, + ), + ); +} + +Future _enterCredentials(WidgetTester tester, {required bool register}) async { + if (register) { + await tester.tap(find.text('没有账号?去注册')); + await tester.pumpAndSettle(); + } + await tester.enterText(find.widgetWithText(TextField, '手机号'), '13912345678'); + await tester.enterText(find.widgetWithText(TextField, '密码'), 'pass123456'); +} + +void main() { + setUp(_mockSecureStorage); + + testWidgets('登录流程:输错密码提示错误,不跳转', (tester) async { + final session = FakeSession(); + final api = FakeOrderApi(session); + await tester.pumpWidget(_app(session, api)); + + await tester.tap(find.text('登录')); + await tester.pumpAndSettle(); + + // 本地校验:空手机号提示 + expect(find.text('请输入手机号和至少 6 位密码'), findsOneWidget); + }); + + testWidgets('注册并登录成功 → 跳转主界面展示未充值', (tester) async { + final session = FakeSession(); + final api = FakeOrderApi(session); + await tester.pumpWidget(_app(session, api)); + + await _enterCredentials(tester, register: true); + await tester.tap(find.text('注册并登录')); + await tester.pumpAndSettle(); + + expect(find.byType(HomeScreen), findsOneWidget); + expect(session.token, 'fake-token'); + expect(session.phone, '13912345678'); + expect(find.textContaining('未充值'), findsOneWidget); + expect(find.text('搜索'), findsOneWidget); + expect(find.text('充值'), findsOneWidget); + }); + + testWidgets('搜索按钮强制服务端校验:未授权弹出拦截弹窗', (tester) async { + final session = FakeSession()..token = 'fake-token'; + final api = FakeOrderApi(session); // active: false + await tester.pumpWidget(_app(session, api, initialRoute: '/home')); + + await tester.tap(find.text('搜索')); + await tester.pumpAndSettle(); + + expect(find.text('授权不可用'), findsOneWidget); + expect(find.text('去充值'), findsOneWidget); + }); + + testWidgets('搜索按钮校验通过 → 进入相机页', (tester) async { + final session = FakeSession()..token = 'fake-token'; + final api = FakeOrderApi( + session, + licenseResult: LicenseStatus( + active: true, + expiresAt: DateTime.now().add(const Duration(days: 1))), + ); + await tester.pumpWidget(_app(session, api, initialRoute: '/home')); + + await tester.tap(find.text('搜索')); + await tester.pumpAndSettle(); + + expect(find.text('camera-stub'), findsOneWidget); + }); +} diff --git a/flutter_app/test/coordinate_mapper_test.dart b/flutter_app/test/coordinate_mapper_test.dart new file mode 100644 index 0000000..3e2e42d --- /dev/null +++ b/flutter_app/test/coordinate_mapper_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:observer/detection/coordinate_mapper.dart'; + +void main() { + test('rotation90_center_mapsToViewCenter', () { + final rect = CoordinateMapper.mapToView( + 0.4, 0.4, 0.6, 0.6, + 90, 1280, 720, 1080, 1920, + ); + expect(rect.centerX, closeTo(540, 1)); + expect(rect.centerY, closeTo(960, 1)); + }); + + test('rotation90_corner_appliesFitCenterOffset', () { + final rect = CoordinateMapper.mapToView( + 0, 0, 0.5, 0.5, + 90, 1280, 720, + 1080, 2400, + ); + expect(rect.left, closeTo(540, 1)); + expect(rect.top, closeTo(240, 1)); + expect(rect.right, closeTo(1080, 1)); + expect(rect.bottom, closeTo(1200, 1)); + }); + + test('rotation0_keepsCoordinates', () { + final rect = CoordinateMapper.mapToView( + 0.2, 0.3, 0.5, 0.7, + 0, 1080, 1920, + 1080, 1920, + ); + expect(rect.left, closeTo(216, 1)); + expect(rect.top, closeTo(576, 1)); + expect(rect.right, closeTo(540, 1)); + expect(rect.bottom, closeTo(1344, 1)); + }); + + test('rotation180_flipsBothAxes', () { + final rect = CoordinateMapper.mapToView( + 0.2, 0.2, 0.4, 0.4, + 180, 1080, 1920, + 1080, 1920, + ); + expect(rect.left, closeTo(648, 1)); + expect(rect.top, closeTo(1152, 1)); + expect(rect.right, closeTo(864, 1)); + expect(rect.bottom, closeTo(1536, 1)); + }); +} diff --git a/flutter_app/test/motion_aggregator_test.dart b/flutter_app/test/motion_aggregator_test.dart new file mode 100644 index 0000000..e2d7972 --- /dev/null +++ b/flutter_app/test/motion_aggregator_test.dart @@ -0,0 +1,68 @@ +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:observer/detection/detection_result.dart'; +import 'package:observer/detection/motion_aggregator.dart'; + +void main() { + // 96x64 图,8x8 块 → 每块 12x8 像素 + + test('noMotion_returnsEmpty', () { + final diff = List.filled(96 * 64, 0); + expect(MotionAggregator.aggregate(diff, 96, 64), isEmpty); + }); + + test('singleBlockMotion_detectsRegion', () { + final diff = List.filled(96 * 64, 0); + for (var y = 24; y < 32; y++) { + for (var x = 24; x < 36; x++) { + diff[y * 96 + x] = 1; + } + } + final regions = MotionAggregator.aggregate(diff, 96, 64); + expect(regions.length, 1); + final r = regions[0]; + expect(r.left, lessThanOrEqualTo(24 / 96)); + expect(r.right, greaterThanOrEqualTo(36 / 96)); + expect(r.top, lessThanOrEqualTo(24 / 64)); + expect(r.bottom, greaterThanOrEqualTo(32 / 64)); + }); + + test('twoSeparateMotions_detectsTwoRegions', () { + final diff = List.filled(96 * 64, 0); + for (var y = 0; y < 8; y++) { + for (var x = 0; x < 12; x++) { + diff[y * 96 + x] = 1; + } + } + for (var y = 48; y < 64; y++) { + for (var x = 72; x < 96; x++) { + diff[y * 96 + x] = 1; + } + } + expect(MotionAggregator.aggregate(diff, 96, 64).length, 2); + }); + + test('globalNoise_filteredOut', () { + final diff = List.filled(96 * 64, 0); + final rnd = math.Random(42); + for (var i = 0; i < diff.length; i++) { + if (rnd.nextDouble() < 0.1) diff[i] = 1; + } + expect(MotionAggregator.aggregate(diff, 96, 64), isEmpty); + }); + + test('centerInRegion_matches', () { + final box = DetectionResult( + label: 'hare', + score: 0.30, + left: 0.2, + top: 0.3, + right: 0.4, + bottom: 0.5); + expect(MotionAggregator.centerInRegion(box, MotionRegion(0.1, 0.2, 0.5, 0.6)), + isTrue); + expect(MotionAggregator.centerInRegion(box, MotionRegion(0.6, 0.7, 0.9, 0.9)), + isFalse); + }); +} diff --git a/flutter_app/test/nms_test.dart b/flutter_app/test/nms_test.dart new file mode 100644 index 0000000..1eb508b --- /dev/null +++ b/flutter_app/test/nms_test.dart @@ -0,0 +1,43 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:observer/detection/detection_result.dart'; +import 'package:observer/detection/nms.dart'; + +DetectionResult box(double l, double t, double r, double b, double score, + {String label = 'x'}) => + DetectionResult( + label: label, score: score, left: l, top: t, right: r, bottom: b); + +void main() { + test('overlappingBoxes_keepHighestScore', () { + final a = box(0.1, 0.1, 0.5, 0.5, 0.8); + final b = box(0.12, 0.12, 0.52, 0.52, 0.6); + final result = nms([a, b], 0.45); + expect(result.length, 1); + expect(result[0].score, closeTo(0.8, 1e-6)); + }); + + test('separateBoxes_bothKept', () { + final a = box(0.1, 0.1, 0.3, 0.3, 0.8); + final b = box(0.7, 0.7, 0.9, 0.9, 0.6); + expect(nms([a, b], 0.45).length, 2); + }); + + test('lowScoreBox_suppressedByHigherScore', () { + final a = box(0.1, 0.1, 0.5, 0.5, 0.9); + final b = box(0.1, 0.1, 0.5, 0.5, 0.5); + final result = nms([b, a], 0.45); + expect(result.length, 1); + expect(result[0].score, greaterThan(0.5)); + }); + + test('iou_nonOverlapping_isZero', () { + final a = box(0.0, 0.0, 0.2, 0.2, 1); + final b = box(0.8, 0.8, 1.0, 1.0, 1); + expect(iou(a, b), closeTo(0, 1e-6)); + }); + + test('iou_identicalBoxes_isOne', () { + final a = box(0.1, 0.1, 0.5, 0.5, 1); + expect(iou(a, a), closeTo(1, 1e-6)); + }); +} diff --git a/flutter_app/test/widget_test.dart b/flutter_app/test/widget_test.dart new file mode 100644 index 0000000..ba66550 --- /dev/null +++ b/flutter_app/test/widget_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:observer/payment/models.dart'; + +void main() { + test('LicenseStatus.isActive 未过期为 true', () { + final license = LicenseStatus( + active: true, + expiresAt: DateTime.now().add(const Duration(days: 1)), + ); + expect(license.isActive, isTrue); + }); + + test('LicenseStatus.isActive 已过期为 false', () { + final license = LicenseStatus( + active: true, + expiresAt: DateTime.now().subtract(const Duration(days: 1)), + ); + expect(license.isActive, isFalse); + }); + + test('LicenseStatus.isActive active=false 恒为 false', () { + final license = LicenseStatus( + active: false, + expiresAt: DateTime.now().add(const Duration(days: 1)), + ); + expect(license.isActive, isFalse); + }); + + test('LicenseStatus.fromJson 解析', () { + final license = LicenseStatus.fromJson({ + 'active': true, + 'expiresAt': '2026-08-29T23:59:59+08:00', + }); + expect(license.active, isTrue); + expect(license.expiresAt, isNotNull); + }); +} diff --git a/gradle.properties b/gradle.properties deleted file mode 100644 index f0a2e55..0000000 --- a/gradle.properties +++ /dev/null @@ -1,4 +0,0 @@ -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -android.useAndroidX=true -kotlin.code.style=official -android.nonTransitiveRClass=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml deleted file mode 100644 index 407954b..0000000 --- a/gradle/libs.versions.toml +++ /dev/null @@ -1,39 +0,0 @@ -[versions] -agp = "8.5.2" -kotlin = "2.0.20" -coreKtx = "1.13.1" -lifecycle = "2.8.6" -activityCompose = "1.9.2" -composeBom = "2024.09.03" -camerax = "1.4.1" -tflite = "2.16.1" -datastore = "1.1.1" -coroutines = "1.9.0" -junit = "4.13.2" - -[libraries] -androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } -androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } -androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } -androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } -androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } -androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } -androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } -androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } -androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } -androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } -androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } -androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" } -androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } -androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } -androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } -org-tensorflow-lite = { group = "org.tensorflow", name = "tensorflow-lite", version.ref = "tflite" } -org-tensorflow-lite-gpu = { group = "org.tensorflow", name = "tensorflow-lite-gpu", version.ref = "tflite" } -androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } -kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } -junit = { group = "junit", name = "junit", version.ref = "junit" } - -[plugins] -android-application = { id = "com.android.application", version.ref = "agp" } -kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } -kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index eddabd2eef8d94a5437d6168ff9c87a78ff725b3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 47505 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 '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat deleted file mode 100644 index a51ec4f..0000000 --- a/gradlew.bat +++ /dev/null @@ -1,82 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem gradlew startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:execute -@rem Setup the command line - - - -@rem Execute gradlew -@rem endlocal doesn't take effect until after the line is parsed and variables are expanded -@rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel - -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/server/CLAUDE.md b/server/CLAUDE.md new file mode 100644 index 0000000..22ddd52 --- /dev/null +++ b/server/CLAUDE.md @@ -0,0 +1,110 @@ +# CLAUDE.md + +## 目录结构与职责(硬性约束) + +> **`biz/` 是泛化占位名,不是固定目录命名**。表格中 `biz/` 代表「业务模块目录」,各项目必须按自身业务命名替换(本项目即 `biz/`),禁止新项目照抄 `biz/`;`ui-src/`、`data/`、`workspace/` 亦为本项目目录名,各项目按自身命名。 + +| 目录 | 职责 | 强约束 | +|---|---|---| +| common/ | 通用层:HTTP 服务与鉴权中间件、文件解析(parser + pdf/docx/html/text)、中文分词、向量 JSON、DAO 基类、查询缓存、协程池封装 | 不得依赖业务模块包;新增跨模块通用能力放这里 | +| biz/consts/ | 常量集中地:表名(table_name.go)、状态(status.go)、内容类型、默认参数与各协程池默认大小(consts.go) | 业务常量一律在此集中,禁止散落 magic number;新增池默认大小在此定义 | +| biz/model/ | entity(表结构,与 DAO 一一对应)、dto(请求/响应结构,`g.Meta` 内嵌定义路由)、domain(领域模型:跨表聚合与服务层组装值,可被 dto/entity 引用) | entity 只做表映射,不带业务逻辑;dto 是 controller 与 HTTP 的唯一出入口;domain 收纳不属于 dto 也不属于 entity 的类型(见下) | +| biz/dao/ | 单表数据访问,每表一个文件 | 无业务逻辑;查询经 base_dao 缓存 | +| biz/service/ | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao、LLM 编排 | 不直接写 HTTP 响应(例外见下);并行任务走 common 协程池 | +| biz/controller/ | 接口层:接收参数、调用 service、组装返回值 | 见「分层职责规范」;禁止调用 dao | +| 前端目录 | 前台 `ui-src/` 为 uni-app (Vue 3) 多端工程(Android/iOS/平板/微信小程序/H5);后台管理端规划中(未建工程) | 前台开发用 vite 代理,生产构建产物 `dist` 由后端托管;多端工程差异见技术设计.md | +| 运行时数据目录 | SQLite 库、上传/解析文件(本项目 `data/` `workspace/`) | 不提交 git;删除即丢失数据,改动前先确认 | + +## 分层职责规范(硬性要求) + +严格分层 `controller → service → dao`,禁止跨层调用(controller 禁止直接调 dao)。 + +| 层 | 目录 | 职责 | 禁止 | +|---|---|---|---| +| controller | biz/controller | 接收 dto 请求参数(依赖 DTO `v` tag 自动校验)调用 service,原样返回 service 结果(返回类型与 service 一致,即 dto);**传参方式:整个 `*dto.XxxReq` 直接传给 service,禁止从 dto 拆出多个属性逐个传参** | 直接调用 dao;任何组装/映射/字段搬运;手写业务规则校验(库表依赖/跨字段,应下沉 service);文件 IO;状态流转;跨表数据组装 | +| service | biz/service | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao;只允许返回 dto 类型(返回与 controller 输出一致的 `*dto.XxxRes`),派生值(如 scene_name/node_count)在 service 用 dto 组装 | 直接写 HTTP 响应(例外见下);返回裸 gdb.Record 或任何非 dto 类型 | +| dao | biz/dao | 构建 SQL 并执行;行→结构体转换在 dao 内部用 GoFrame 自带方法(`Record.Struct` / `Result.Structs`,按 `orm` tag)完成,对外只允许返回 entity(或单值如 int/map) | 业务逻辑;返回裸 gdb.Record——**裸 gdb.Record 不允许作为任何分层方法的返回值**(含 service 内事务读),转换只发生在 dao 内部,不外泄 | + +**例外**:SSE 流式响应、HTML/文件导出等"直接写响应体"的场景由 controller 完成——这是"值返回"的流式形式,事件序列化、心跳属 HTTP 协议职责,保留在 controller。 + +**分层锚定原则(controller 反向锚定,防跑偏)**:controller 只透传 ⇒ 接口返回类型以 dto 为准 ⇒ service 返回类型被 dto 锁死 ⇒ dao 输出被 entity 锁死。任何一层若出现"为下一层做数据搬运"(controller 映射 service 结果、service 逐键取裸 Record 字段),即违反本原则,应向上收敛:转换在 dao 内部(Record→entity)、组装在 service(entity→dto)、透传在 controller。 + +**教训(此前偏离原因,开发时引以为戒)**: +1. 自定义中转层不得替代 dto:domain 只收"不进 HTTP 出入参"的纯领域值(如结算输入 SettleState/结算结果 FinalSettle);凡出现在接口出入参中的类型一律用 dto,由 service 直接产出——否则 controller 被迫承担映射,违反"controller 只透传" +2. dao 必须完成行→结构体转换:裸 gdb.Record 的 string 键取值是魔法值,拼错列名编译期不报错,且会把键取值扩散到 service/controller;GoFrame 自带 `Record.Struct`/`Result.Structs`(按 `orm` tag)即转换手段 +3. 新分层先对齐框架惯例:GoFrame 原生分层即 dao 转 entity / service 返回 dto / controller 薄透传,自定义设计前先核对框架默认范式 + +## 分层文件对齐与代码模式(硬性要求) + +- 每张业务表对应一组 `entity / dao / service / controller / dto` 文件,数量严格对齐(核验方式:每层目录文件数 = 分层表数,分层表数 = 总表数 − 豁免表数);虚拟表(向量 vec0 / FTS5)与**流水/记录类表(如 point_log)豁免分层对齐**:不建任何独立分层文件(含 entity/dao),建表由主表 dao 统一管理(同虚拟表模式),由使用方 service 事务内直写,禁止为只写不读的审计表造分层门面;无任何读写引用的死表连表带分层整套删除,启动时 DROP 库内残留表与代码保持一致 +- **非表文件一律不进业务分层目录**:路由注册与中间件装配、表初始化列表(建表 + 死表 DROP)直接写在 `main.go`;鉴权等跨模块通用能力放 `common/`;跨表业务流程归入所属表文件(如闯关 Choose 属 level 表)——分层目录出现非表文件即违反对齐,禁止以非表名开独立分层文件 +- **不建 parser/rag 等技术目录**:纯技术能力(文档解析、中文分词、向量序列化)平铺在 `common/`;业务编排(分块、检索、工作流)归入对应 service 文件 +- entity:每文件一张表,`orm` 标签与列名一致,时间字段用 `*gtime.Time`,只做表映射 +- dto:请求/响应结构,`g.Meta` 内嵌定义路由;只描述 HTTP 出入参,不承载领域逻辑 +- **domain(目录 `biz/model/domain/`,package domain)**:仅收纳**不进 HTTP 出入参**的纯领域值(如结算输入 SettleState/结算结果 FinalSettle,service 内部流转 + 单测使用);判断标准:类型是否出现在接口出入参中——出现即用 dto。entity 对表、dto 对 HTTP、domain 对纯领域;service 返回 dto(允许 import dto),dao 返回 entity(禁止外泄 gdb.Record) +- dao:单例 `var Xxx = &xxxDao{}`,`init()` 内 `CREATE TABLE IF NOT EXISTS` + 索引 + 迁移;通用 CRUD 复用 `common/base_dao.go`(InsertAndReturnId / GetOneByPk / UpdateByPk) +- controller:路由**反射注册**(`common.BindController`),path/method 唯一来源为 dto 的 `g.Meta`(携带 path/method/summary);新增接口只需写 dto + controller 方法,禁止在 main.go 手动逐条注册;跨组方法以 `H5` 前缀命名(h5 组只注册 H5 开头方法,admin 组跳过) +- **接口只允许 GET / POST**:写操作传 JSON body(或 multipart),读操作走 query params;无 PUT/DELETE +- dao 查询缓存:查询用 `gdb.CacheOption`(TTL 来自配置),**写操作后必须清对应缓存**,否则出现"库里已改、查询还是旧值" + +## 错误处理规范(硬性要求) + +- 所有可能失败的调用必须显式处理返回的 error:向上返回(保留上下文用 `gerror.Wrap`/`Newf`)或记录日志,禁止 `_, _ =` 静默丢弃——吞错会掩盖故障根因,修复问题必须先定位错误路径,不得以忽略 error 换取编译通过 +- defer 关闭等无法向上返回的资源清理错误,用 `defer func() { _ = x.Close() }()` 显式声明忽略意图,禁止裸 `defer x.Close()` 隐式吞错 + +## 并发规范 + +- **可并行的场景**:纯 IO 任务——读查询、LLM/Embedding 调用、文件读取。SQLite 写一律回主 goroutine 串行(无 WAL 时并发写会 `database is locked`,锁定风险归零,并发只赢在 IO 等待上) +- **新增并行点的固定三处**:common 加池封装(grpool)→ `biz/consts` 加默认大小 → `config.yml` 加 `key: 并发度`(缺失或非法时回退默认值) +- 禁止直接用裸 `go` 启动并行工作负载,一律走 `common` 的池 +- **防死锁**:等待链单向「主 → A池 → B池」,被等待池的任务内不得再等待任何池(会饿死 worker);池无 Wait 方法,等待用调用方 `sync.WaitGroup`,任务结果经 buffered channel 回主 goroutine +- **共享状态安全(内存)**:池内任务并发执行,共享实例(service 单例、model 句柄等)只允许**只读**访问;可变字段必须在**提交池之前**由主 goroutine 一次性预置,任务内禁止写共享字段——Go map 并发写直接 `fatal error: concurrent map writes`,无锁、无降级、不可恢复,只能崩溃重启。需要可变共享状态时按优先级:① 无共享(任务内新建、buffered channel 传递结果)② 锁(`sync.Mutex`/`RWMutex`,锁内只做内存操作,LLM/DB 等 IO 放锁外)③ `sync/atomic`(仅限 int 类标量计数/标志,如 `atomic.AddInt64`,并发计数禁用普通 `++`;复合结构不要用 atomic,指针 CAS 属例外) +- **锁的使用**:互斥场景唯一入口是 `common.WithLock[T any](ctx, key, expire, retries, retryInterval, fn func() (T, error)) (T, error)`——泛型回调,业务返回值经 T 原样透出给下游;内部按 config.yml 自动选择锁实现(配置了 `redis` 节点 → redis 锁,跨实例互斥,SET NX EX + token 对比删除防误删他人锁;未配置 → gcache 内存锁,单实例互斥),禁止直接用 gcache/gredis 自己实现加锁。拿不到锁(被占用,`ErrLockHeld`)最多重试 `retries` 次、每次间隔 `retryInterval`(`retries=0` 立即失败;ctx 取消/超时同样终止等待);中间件故障不重试直接返回。锁自动释放:无论 fn 成功、失败还是 panic,defer 释放。`expire` 必须 > 0(进程崩溃兜底不死锁),fn 耗时必须在 expire 前完成,fn 内禁止长耗时 IO(LLM/DB 调用);锁粒度按业务唯一键尽量小 +- **裸 `go` 允许的例外**:`go func(){ wg.Wait(); close(ch) }()` 收尾惯用法、SSE 心跳、流式管道(Stream 读写)等长生命周期/非工作负载协程 + +## 文档职责(三文档体系) + +| 文档 | 职责 | 何时补充/更新 | +|---|---|---| +| CLAUDE.md(本文件) | 公司通用开发规范:分层职责、代码模式、并发/事务/缓存约束、流程 | 规范变化时 | +| README.md | 项目功能介绍:架构、数据流、表清单、功能模块、API 清单、使用说明 | 功能增减时 | +| 技术设计.md | 实现细节与技术决策:DDL、检索参数、风险与备选方案 | 关键技术决策/参数变化时 | + +## 开发流程(文档驱动,硬性要求) + +永远以文档驱动开发:用户提出开发需求 → 先给出实现方案(技术选型、影响面、改动清单) → **用户确认后先补充文档再动手写代码**。补充哪个文档取决于内容性质:规范 → 本文件,功能 → README,实现细节/技术决策 → 技术设计.md。禁止未经确认直接开发,禁止先写代码后补文档。 + +## 数据访问规范(硬性要求) + +- **事务**:涉及多张表的增删改操作必须包数据库事务,禁止逐表裸调用。**事务必须在 service 层**(`g.DB().Transaction` 包裹与事务内写方法如 `XxxInTx`,service 持有 tx 句柄编排多表),dao 层只做单表无状态 CRUD,不持事务;`tx.Begin` 后必须用 `defer` 防护已提交后的二次 Rollback +- **SQL 单表约束**:每个 SQL 只允许访问一张表,禁止 JOIN 与跨表子查询(`IN (SELECT ...)` / `EXISTS`);跨表数据一律拆为多条单表 SQL + 应用层内存组装——先取外键 id 列表,再对目标表 `IN` 查询;`IN` 参数须按 ≤100 分批(SQLite 变量数上限 999) +- **禁止 N+1 查询**:禁止在循环中逐条查库。循环场景一律改为批处理——一次 `ListByXxx` 取回后按外键在内存分组 +- **缓存一致性**:DAO 查询走缓存(TTL 来自 `database.cache.ttl`),写操作后必须清对应缓存 +- **批处理 SQL**:批量写入用 `InsertAll` 类方法,批量删除用 `IN` 子句,禁止循环单条 INSERT/DELETE +- **配置即使用**:config.yml 中出现 redis / mq 等中间件配置时,代码必须实际接入使用,禁止"配置了但代码不用"或"代码写死但配置缺失" +- **消息/回调幂等**:接入 MQ / Webhook 时,消费与回调处理必须幂等——MQ 至少一次语义、webhook 失败重试都可能重复投递同一事件,禁止依赖"只投一次"假设。幂等手段:以业务唯一键(如 `任务ID + 事件类型`)先查重或建唯一约束再落库,重复事件直接忽略;重试与补偿逻辑同样要防重复执行 + +## 运维部署规范(硬性要求) + +- **部署形态**:Docker Compose 单机部署,前后端一体单端口;运行时数据目录挂载持久化,容器重建不丢数据。部署文件在项目根目录(`Dockerfile` / `docker-compose.yml`),启动与使用见 README +- **数据即文件**:文件型存储,备份 = 打包「数据库目录(小而关键)+ 上传文件目录(大而可重建)」两个目录;迁移 = 拷贝到新机器即可 +- **运行时数据与代码分离**:数据目录不提交 git;**删除即丢数据,改动前先确认** +- **配置即文件**:项目配置文件为唯一配置入口(监听端口、并发度等),环境变量覆盖无效 + +## 金额单位规范(硬性要求) + +- **金额一律以「分」为单位存储与传输**:所有金额字段(零售价、进价、单价、小计、总金额)在数据库与接口中使用整数分(entity/dto 字段类型 `int64`),禁止浮点元 +- **展示转换在前端**:前端展示 ÷100 转元保留两位小数,提交 ×100 转分;禁止后端做元↔分转换,后端只处理整数分 +- **金额计算**:`数量(浮点克) × 单价(分)` 后必须 `common.RoundInt` 四舍五入到整数分,禁止裸浮点累加 +- **量纲区分**:数量/库存/用量为浮点克(REAL),金额为整数分;混算时必须显式转换 +- 存量库迁移以 `PRAGMA user_version` 版本化标记,禁止重复执行 + +## 约定 + +- controller 方法签名固定为 `(ctx, *dto.XxxReq) (*dto.XxxRes, error)`,实例注册模式 `var Xxx = &xxx{}` +- **参数校验优先用 GoFrame DTO 校验**:请求结构体用 `v` tag(required / regex / in 等)声明,框架自动校验并返回错误,controller 不手写校验;仅 DTO 表达不了的业务规则(跨字段依赖、查库校验如重名、取值范围依赖配置)放 service。JSON 格式解析可留在 controller 或下沉 service,但须保持与调用点一致 +- 响应组装(实体 → DTO 字段映射)在 controller 进行 +- service 方法签名 ctx 开头,错误统一用 `gerror` +- 编译验证:`go build ./...` +- **列表页单元格禁止换行(布局优先级)**:单元格一律单行。排布顺序:① 列宽按数据量设足(自由文本列按实际长度加宽) → ② 列多表宽时用固定列(fixed,如操作列 fixed right) → ③ 最后才对超长文本用 `show-overflow-tooltip` 省略兜底;禁止无 tooltip 的 min-width 列静默截断。全局 `admin-src/src/style.css` 的 `.el-table .cell { white-space: nowrap }` 是最终兜底,新列表页不得依赖换行展示 +- **H5 客户侧文案合规(硬性)**:H5 页面与微信模板消息面向终端消费者,软件方无医疗资质,禁止出现 诊所/开方/处方/药方/医嘱/药品/服药/诊疗 等医疗行为用语——暗示诊疗即违规;一律用中性话术:「健康打卡」「调理」「饮食禁忌提醒」「联系服务机构」等。后台管理端(admin-src)是持牌机构内部经营工具,业务词汇(药品/处方/开方)不受此限,但登录页与浏览器标题等可被搜索引擎收录的对外表面保持中性品牌名 diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..028b600 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,23 @@ +# 视野后端构建:多阶段编译 → 精简运行镜像(纯 Go + SQLite,无 CGO 依赖) +FROM golang:1.26 AS builder + +WORKDIR /build +# 依赖层缓存:go.mod/go.sum 变更才重拉依赖 +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 go build -o /out/observer-server . + +FROM alpine:3.20 +# 时区对齐宿主(自然日授权语义依赖本地时区) +RUN apk add --no-cache tzdata && \ + cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ + echo "Asia/Shanghai" > /etc/timezone +WORKDIR /app +COPY --from=builder /out/observer-server /app/observer-server +COPY config.yml /app/config.yml +# 运行时数据目录(SQLite 库),由 docker-compose 挂载持久化 +RUN mkdir -p /app/data +EXPOSE 8080 +ENTRYPOINT ["/app/observer-server"] diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..e6a53f8 --- /dev/null +++ b/server/README.md @@ -0,0 +1,158 @@ +# 视野 后端服务 + +野生动物实时识别 App「视野」(Flutter 客户端见 `../flutter_app/`)的支付与授权后端。 + +## 功能模块 + +| 模块 | 说明 | +|---|---| +| 账号 | 手机号+密码注册/登录(`POST /api/v1/auth/register` / `login`),密码 bcrypt 哈希存储,登录签发自签名 token(`auth.secret`),授权绑定手机号账号、同账号多设备共享 | +| 订单 | 客户端 `GET /api/v1/plans` 拉取套餐价格 → `POST /api/v1/orders` 创建订单并返回支付参数(微信 prepay / 支付宝 orderStr);未支付订单 2 小时惰性关闭,同账号 2 小时内复用 created 订单防重复扣款 | +| 支付回调 | 微信支付 / 支付宝异步回调验签落授权(`/api/v1/payment` 分组,按渠道应答格式直返,不做统一包装) | +| 订单确认 | 客户端支付成功后 `POST /api/v1/orders/{orderId}/confirm` 幂等通知,加速授权刷新 | +| 授权查询 | `GET /api/v1/license`(Bearer token)返回授权状态,App 识别入口强制服务端校验 | +| 套餐 | `config.yml` `plans` 节点配置三档套餐(改价 = 改配置重启),价格**整数分**(1000 / 5600 / 18000) | +| 后台管理端 | `server_admin/`(Vue3 + Element Plus)管理页面:订单查询、账号/授权管理(手动授权/撤销);构建产物由后端 `/admin/` 托管,登录页输入 token 后以 `X-Admin-Token` 头鉴权(`config.yml admin.token`) | + +## 架构与数据流 + +``` +Flutter App ── POST /auth/register|login ─► 账号注册/登录,签发 token + │ + ├─► POST /orders (Bearer token) ──► 后端下单(校验 planId/channel) + │ │ + │◄───────┘ orderId + payParams + │ + ├─► 拉起微信/支付宝 SDK 支付 + │ + ├─► 微信/支付宝服务端异步回调 ─► 验签 → 落授权(幂等,绑账号) + │ + └─► POST /orders/{id}/confirm (Bearer token) ─► 幂等确认(回调已落授权则直接返回) + │ + └─► GET /license (Bearer token) ─► 返回 {active, expiresAt} +``` + +- 授权以**服务端为准**:客户端本地缓存 `expiresAt` 仅用于主页展示,识别入口强制查询服务端 +- 有效期语义:**自然日**(day 当天 24:00 失效;week/month 自生效日起自然日累计),服务端计算 `expiresAt`,客户端只读不判权 + +## 数据表 + +| 表 | 说明 | 关键字段 | +|---|---|---| +| `payment_order` | 支付订单 | `order_id`(PK)、`phone_num`、`plan_id`、`channel`(wechat/alipay)、`amount_cents`、`status`(created/paid/closed)、`wx_trade_no`(UNIQUE)、`alipay_trade_no`(UNIQUE)、`created_at`、`paid_at` | +| `license` | 手机号账号与授权 | `phone_num`(PK)、`password`(bcrypt)、`expires_at`(未充值 NULL)、`created_at`、`updated_at` | + +建表与迁移见 `技术设计.md`(新库直接建表;存量库以 `PRAGMA user_version` 版本化迁移)。 + +## API 清单 + +响应统一格式 `{"code": 0, "message": "ok", "data": {...}}`,`code != 0` 视为失败。接口只允许 GET / POST。 + +### POST /api/v1/auth/register + +手机号注册(与登录无关的公开接口,无需 token)。请求:`{"phone": "13800000000", "password": "..."}`。已注册手机号返回错误。注册成功即创建账号(未充值,无额度)。 + +### POST /api/v1/auth/login + +手机号登录。请求:`{"phone": "13800000000", "password": "..."}`。响应 `data: {"token": "..."}`(HMAC 自签名,有效期 `auth.token_ttl`,默认 30 天)。后续登录态接口携带 `Authorization: Bearer `。 + +### GET /api/v1/plans + +套餐列表(需 Bearer token)。响应 `data`: + +```json +{ + "list": [ + {"planId": "day", "days": 1, "priceCents": 1000}, + {"planId": "week", "days": 7, "priceCents": 5600}, + {"planId": "month", "days": 30, "priceCents": 18000} + ] +} +``` + +套餐来自 `config.yml` `plans` 节点(静态定价,改价改配置后重启服务生效);客户端不硬编码价格。展示名由客户端按 `days` 拼「N天」派生,无独立 label 字段。 + +### POST /api/v1/orders + +创建订单,返回渠道支付参数(需 Bearer token,手机号由 token 解出)。 + +请求(JSON body): + +```json +{"planId": "week", "channel": "wechat"} +``` + +- `channel`:`wechat` | `alipay` +- `planId`:`day` | `week` | `month`(需在 `config.yml` `plans` 节点存在,金额由服务端按配置锁定,客户端不可改价) + +响应 `data`: + +```json +{ + "orderId": "O20260822001", + "payParams": { + "partnerId": "1900xxxxx", + "prepayId": "wx...", + "nonceStr": "...", + "timeStamp": "1728000000", + "sign": "...", + "packageValue": "Sign=WXPay" + } +} +``` + +- `channel == wechat`:`payParams` 为微信 APP 支付统一下单返回参数(后端签名) +- `channel == alipay`:`payParams` 为 `{"orderStr": "alipay_sdk=..."}`(后端调 `alipay.trade.app.pay` 生成) + +### POST /api/v1/orders/{orderId}/confirm + +客户端支付成功后通知(幂等,需 Bearer token)。服务端以异步回调为准落授权,confirm 仅用于加速刷新;回调已到账时直接返回成功。 + +请求:`{}`(无参数)。响应:`data: {"status": "paid"|"created"}`。 + +返回 `created`(回调未到账)时客户端按 3s → 10s → 30s 递增重试 confirm(最多 3 次),期间禁止重新下单;仍未成功回退 `GET /license` 查询(回调到账后自然变为 active)。回调为唯一授权来源,confirm 仅加速刷新。 + +### GET /api/v1/license + +查询账号授权状态(需 Bearer token,手机号由 token 解出)。 + +响应 `data`: + +```json +{"active": true, "expiresAt": "2026-08-29T23:59:59+08:00"} +``` + +`active: false` 或 `expiresAt` 已过期(含未充值账号)→ 客户端展示付费墙/充值引导。 + +### 支付回调(微信/支付宝,勿直连) + +| 渠道 | 路径 | 说明 | +|---|---|---| +| 微信支付(APP 支付 V3) | `POST /api/v1/payment/wechat/notify` | 解密+验签回调,`out_trade_no` → 落授权 | +| 支付宝(APP 支付) | `POST /api/v1/payment/alipay/notify` | RSA2 验签回调,`out_trade_no` → 落授权 | + +### 管理端接口(`/api/v1/admin`,需请求头 `X-Admin-Token` = `config.yml admin.token`) + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/admin/orders` | 订单列表,筛选参数 `phoneNum/status/startAt/endAt` + `page/size`,返回 `{total, list}` | +| GET | `/admin/licenses` | 账号/授权列表,筛选 `phoneNum` + `page/size`(含未充值账号) | +| POST | `/admin/licenses/grant` | 手动授权 `{"phoneNum":"13800000000","planId":"day"}`,自然日叠加 | +| POST | `/admin/licenses/revoke` | 撤销授权 `{"phoneNum":"13800000000"}`(清授权保留账号) | + +管理页面(订单/授权)由 `server_admin/` 构建产物提供,访问 `http:///admin/`。金额均为整数分,前端展示 ÷100 转元。 + +## 使用说明 + +1. 配置 `config.yml`:监听端口、数据库路径、登录 token 签名密钥 `auth.secret`(必填,换值即全员下线)、套餐 `plans` 节点、微信支付(appid/mchid/商户私钥/证书序列号/APIv3 密钥)、支付宝(appid/应用私钥/支付宝公钥)、管理端 `admin.token`;SQLite 库由服务启动时自动建表并迁移,无需手工初始化 +2. `go build ./...` 编译验证 +3. 本地运行 `go run main.go`;服务层白盒测试:`GF_GCFG_FILE=biz/service/testdata/config.yml go test ./biz/service/`(独立测试库,见 `biz/service/testdata/`) +4. 后台管理端:`cd server_admin && npm run build`(构建产物输出到 `server/admin_dist/`,由后端 `/admin/` 托管);开发联调 `npm run dev`(Vite 代理 `/api` → `:8080`)。首次访问 `/admin/` 进入登录页,输入 `config.yml admin.token` 对应的管理 token(存浏览器 localStorage,随请求携带;token 不内嵌构建产物) +5. Docker Compose 部署见 `Dockerfile` / `docker-compose.yml`(`./data` 运行时数据目录挂载持久化,容器重建不丢数据) +6. 客户端对接:`flutter_app/lib/config/app_config.dart` 的 `apiBaseUrl`、微信/支付宝 AppID 与 URL Scheme 替换为真实值 + +## 金额与安全约定 + +- 金额一律**整数分**(`price_cents` / `amount_cents` 为 INT64),禁止浮点元;前端展示 ÷100 +- 支付回调必须**验签**(微信 APIv3 验签/解密、支付宝 RSA2),回调处理**幂等**(`wx_trade_no`/`alipay_trade_no` 唯一约束,重复回调直接忽略) +- 授权落库以**较晚到期为准**:已有授权未过期时新套餐顺延叠加,禁止覆盖缩短 diff --git a/server/biz/consts/consts.go b/server/biz/consts/consts.go new file mode 100644 index 0000000..12034fd --- /dev/null +++ b/server/biz/consts/consts.go @@ -0,0 +1,22 @@ +package consts + +// 业务常量集中地:表名、状态、默认参数与协程池默认大小。 + +const ( + TablePaymentOrder = "payment_order" + TableLicense = "license" + + // 订单状态机 created → paid(closed 仅超时/失败关闭) + OrderStatusCreated = "created" + OrderStatusPaid = "paid" + OrderStatusClosed = "closed" + + ChannelWechat = "wechat" + ChannelAlipay = "alipay" + + // 未支付订单惰性关闭阈值与同设备复用窗口(秒) + PendingOrderWindowSeconds = 2 * 3600 + + // 回调 IO 池默认并发度(被 config.yml payment.poolSize 覆盖) + PaymentPoolDefaultSize = 16 +) diff --git a/server/biz/controller/admin.go b/server/biz/controller/admin.go new file mode 100644 index 0000000..ab18413 --- /dev/null +++ b/server/biz/controller/admin.go @@ -0,0 +1,35 @@ +package controller + +import ( + "context" + + "observer-server/biz/model/dto" + "observer-server/biz/service" +) + +// cAdmin 后台管理端接口:薄透传;service 按表归入各表文件 +// (订单→service.Order、授权→service.License)。 +// 独立 controller 聚合,避免同一 controller 挂客户端组 + 管理组时路由重复注册。 +type cAdmin struct{} + +var Admin = &cAdmin{} + +// ListOrders 订单列表 +func (c *cAdmin) ListOrders(ctx context.Context, req *dto.AdminOrderListReq) (*dto.AdminOrderListRes, error) { + return service.Order.AdminListOrders(ctx, req) +} + +// ListLicenses 授权列表 +func (c *cAdmin) ListLicenses(ctx context.Context, req *dto.AdminLicenseListReq) (*dto.AdminLicenseListRes, error) { + return service.License.AdminListLicenses(ctx, req) +} + +// Grant 手动授权 +func (c *cAdmin) Grant(ctx context.Context, req *dto.AdminGrantReq) (*dto.AdminGrantRes, error) { + return service.License.AdminGrant(ctx, req) +} + +// Revoke 撤销授权 +func (c *cAdmin) Revoke(ctx context.Context, req *dto.AdminRevokeReq) (*dto.AdminRevokeRes, error) { + return service.License.AdminRevoke(ctx, req) +} diff --git a/server/biz/controller/auth.go b/server/biz/controller/auth.go new file mode 100644 index 0000000..2d6fcc3 --- /dev/null +++ b/server/biz/controller/auth.go @@ -0,0 +1,23 @@ +package controller + +import ( + "context" + + "observer-server/biz/model/dto" + "observer-server/biz/service" +) + +// cAuth 账号接口:注册/登录(公开,无需登录态) +type cAuth struct{} + +var Auth = &cAuth{} + +// Register 手机号注册 +func (c *cAuth) Register(ctx context.Context, req *dto.RegisterReq) (*dto.RegisterRes, error) { + return service.Auth.Register(ctx, req) +} + +// Login 手机号登录,返回 token +func (c *cAuth) Login(ctx context.Context, req *dto.LoginReq) (*dto.LoginRes, error) { + return service.Auth.Login(ctx, req) +} diff --git a/server/biz/controller/license.go b/server/biz/controller/license.go new file mode 100644 index 0000000..fa818fc --- /dev/null +++ b/server/biz/controller/license.go @@ -0,0 +1,18 @@ +package controller + +import ( + "context" + + "observer-server/biz/model/dto" + "observer-server/biz/service" +) + +// cLicense 授权接口 +type cLicense struct{} + +var License = &cLicense{} + +// Get 查询授权状态 +func (c *cLicense) Get(ctx context.Context, req *dto.LicenseReq) (*dto.LicenseRes, error) { + return service.License.Get(ctx, req) +} diff --git a/server/biz/controller/order.go b/server/biz/controller/order.go new file mode 100644 index 0000000..423caa0 --- /dev/null +++ b/server/biz/controller/order.go @@ -0,0 +1,28 @@ +package controller + +import ( + "context" + + "observer-server/biz/model/dto" + "observer-server/biz/service" +) + +// cOrder 订单接口:薄透传,组装在 service +type cOrder struct{} + +var Order = &cOrder{} + +// ListPlans 套餐列表(config.yml plans) +func (c *cOrder) ListPlans(ctx context.Context, req *dto.ListPlansReq) (*dto.ListPlansRes, error) { + return service.Order.ListPlans(ctx, req) +} + +// Create 创建支付订单 +func (c *cOrder) Create(ctx context.Context, req *dto.CreateOrderReq) (*dto.CreateOrderRes, error) { + return service.Order.Create(ctx, req) +} + +// Confirm 确认支付结果 +func (c *cOrder) Confirm(ctx context.Context, req *dto.ConfirmOrderReq) (*dto.ConfirmOrderRes, error) { + return service.Order.Confirm(ctx, req) +} diff --git a/server/biz/controller/payment.go b/server/biz/controller/payment.go new file mode 100644 index 0000000..e785326 --- /dev/null +++ b/server/biz/controller/payment.go @@ -0,0 +1,55 @@ +package controller + +import ( + "context" + + "github.com/gogf/gf/v2/frame/g" + + "observer-server/biz/model/dto" + "observer-server/biz/service" + "observer-server/common" +) + +// cPayment 支付回调接口:按渠道应答格式直接返回,不做统一响应包装 +type cPayment struct{} + +var Payment = &cPayment{} + +// WechatNotify 微信支付回调:验签+落授权在回调池执行,结果决定 SUCCESS/FAIL 应答; +// 应答由本 controller 直接写 JSON(微信要求的格式),返回 nil res +func (c *cPayment) WechatNotify(ctx context.Context, req *dto.WechatNotifyReq) (*dto.WechatNotifyRes, error) { + r := g.RequestFromCtx(ctx) + res := &dto.WechatNotifyRes{Code: "SUCCESS"} + err := common.CallbackPoolInstance().Submit(ctx, func(ctx context.Context) error { + return service.Payment.WechatNotify(ctx, r.Request.Header, r.GetBody()) + }) + if err != nil { + g.Log().Warningf(ctx, "微信回调处理失败: %+v", err) + res.Code = "FAIL" + res.Message = err.Error() + } + r.Response.WriteJson(res) + return nil, nil +} + +// AlipayNotify 支付宝回调:直接写 "success"/"failure" 文本(支付宝约定的应答格式), +// 返回 nil res(框架对 nil 不再写响应体) +func (c *cPayment) AlipayNotify(ctx context.Context, req *dto.AlipayNotifyReq) (*dto.AlipayNotifyRes, error) { + r := g.RequestFromCtx(ctx) + if err := r.Request.ParseForm(); err != nil { + g.Log().Warningf(ctx, "支付宝回调表单解析失败: %+v", err) + r.Response.Write("failure") + return nil, nil + } + values := r.Request.PostForm + err := common.CallbackPoolInstance().Submit(ctx, func(ctx context.Context) error { + return service.Payment.AlipayNotify(ctx, values) + }) + if err != nil { + g.Log().Warningf(ctx, "支付宝回调处理失败: %+v", err) + r.Response.Write("failure") + return nil, nil + } + r.Response.Write("success") + return nil, nil +} diff --git a/server/biz/dao/license.go b/server/biz/dao/license.go new file mode 100644 index 0000000..1552659 --- /dev/null +++ b/server/biz/dao/license.go @@ -0,0 +1,134 @@ +package dao + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + "observer-server/biz/consts" + "observer-server/biz/model/entity" + "observer-server/common" +) + +// License 账号授权表 DAO:一手机号一记录(账号+授权合一);查询走缓存,写后必须清缓存。 +type licenseDao struct{} + +var License = &licenseDao{} + +func init() { + ctx := context.Background() + common.DropLegacyTableIfHasColumn(ctx, consts.TableLicense, "device_id") + _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS license ( + phone_num TEXT PRIMARY KEY, + password TEXT NOT NULL, + expires_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +)`) + if err != nil { + panic(err) + } +} + +// GetByPhone 按手机号查询(走缓存,键含手机号) +func (d *licenseDao) GetByPhone(ctx context.Context, phone string) (*entity.License, error) { + return d.getByPhone(ctx, nil, phone, true) +} + +// GetByPhoneNoCache 按手机号查询(绕过缓存:登录校验密码必须即时读库) +func (d *licenseDao) GetByPhoneNoCache(ctx context.Context, phone string) (*entity.License, error) { + return d.getByPhone(ctx, nil, phone, false) +} + +// GetByPhoneInTx 事务内按手机号查询:读改写链路内必须用此方法(绕过缓存, +// 防止并发写后缓存回填旧值导致续费叠加错乱) +func (d *licenseDao) GetByPhoneInTx(ctx context.Context, tx gdb.TX, phone string) (*entity.License, error) { + return d.getByPhone(ctx, tx, phone, false) +} + +func (d *licenseDao) getByPhone(ctx context.Context, tx gdb.TX, phone string, cache bool) (*entity.License, error) { + m := g.DB().Model(consts.TableLicense).Ctx(ctx) + if tx != nil { + m = m.TX(tx) + } else if cache { + m = m.Cache(common.CacheOption(ctx, "license:"+phone)) + } + var e entity.License + if err := m.Where("phone_num", phone).Scan(&e); err != nil { + if common.IsNoRows(err) { + return nil, nil + } + return nil, err + } + return &e, nil +} + +// Register 注册账号:插入 license 行(无授权);冲突由 service 先查后插保证。 +func (d *licenseDao) Register(ctx context.Context, tx gdb.TX, phone, passwordHash string) error { + now := gtime.Now() + _, err := g.DB().Model(consts.TableLicense).Ctx(ctx).TX(tx).Insert(g.Map{ + "phone_num": phone, + "password": passwordHash, + "expires_at": nil, + "created_at": now, + "updated_at": now, + }) + return err +} + +// SetPasswordInTx 补写密码(注册时已有运营发卡占位行:只补密码,不覆盖授权) +func (d *licenseDao) SetPasswordInTx(ctx context.Context, tx gdb.TX, phone, passwordHash string) error { + _, err := g.DB().Model(consts.TableLicense).Ctx(ctx).TX(tx). + Where("phone_num", phone). + Data(gdb.Map{"password": passwordHash, "updated_at": gtime.Now()}). + Update() + return err +} + +// UpsertAuthInTx 落授权(支付回调/手动授权):INSERT 占位(password 空串,运营发卡场景) +// 或 ON CONFLICT 只更新授权字段——password/created_at 一律不动,防止覆盖已注册账号密码。 +func (d *licenseDao) UpsertAuthInTx(ctx context.Context, tx gdb.TX, phone string, expiresAt any, updatedAt any) error { + _, err := tx.Exec(`INSERT INTO license (phone_num, password, expires_at, created_at, updated_at) +VALUES (?, '', ?, ?, ?) +ON CONFLICT(phone_num) DO UPDATE SET + expires_at = excluded.expires_at, + updated_at = excluded.updated_at`, + phone, expiresAt, updatedAt, updatedAt) + return err +} + +// ClearAuthInTx 事务内撤销授权(管理端 revoke):清空授权字段、保留账号行(密码不动) +func (d *licenseDao) ClearAuthInTx(ctx context.Context, tx gdb.TX, phone string) error { + _, err := g.DB().Model(consts.TableLicense).Ctx(ctx).TX(tx). + Where("phone_num", phone). + Data(gdb.Map{"expires_at": nil, "updated_at": gtime.Now()}). + Update() + return err +} + +// PageByPhone 管理端账号分页查询:phone 模糊筛选,按更新时间倒序; +// 列表不缓存(单点 GetByPhone 仍走缓存,写后清缓存不变)。 +func (d *licenseDao) PageByPhone(ctx context.Context, phone string, page, size int) ([]*entity.License, int64, error) { + base := func() *gdb.Model { + m := g.DB().Model(consts.TableLicense).Ctx(ctx) + if phone != "" { + m = m.WhereLike("phone_num", "%"+phone+"%") + } + return m + } + total, err := base().Count() + if err != nil { + return nil, 0, err + } + var list []*entity.License + err = base().OrderDesc("updated_at").Limit((page-1)*size, size).Scan(&list) + if err != nil { + if common.IsNoRows(err) { + return []*entity.License{}, int64(total), nil + } + return nil, 0, err + } + return list, int64(total), nil +} diff --git a/server/biz/dao/payment_order.go b/server/biz/dao/payment_order.go new file mode 100644 index 0000000..3d97e22 --- /dev/null +++ b/server/biz/dao/payment_order.go @@ -0,0 +1,166 @@ +package dao + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + "observer-server/biz/consts" + "observer-server/biz/model/entity" + "observer-server/common" +) + +// PaymentOrder 支付订单表 DAO:写频繁表不走查询缓存; +// 所有写链路(含读改写事务)必须经 common.Serial() 串行执行。 +type paymentOrderDao struct{} + +var PaymentOrder = &paymentOrderDao{} + +func init() { + ctx := context.Background() + common.DropLegacyTableIfHasColumn(ctx, consts.TablePaymentOrder, "device_id") + _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS payment_order ( + order_id TEXT PRIMARY KEY, + phone_num TEXT NOT NULL, + plan_id TEXT NOT NULL, + channel TEXT NOT NULL, + amount_cents INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'created', + wx_trade_no TEXT UNIQUE, + alipay_trade_no TEXT UNIQUE, + created_at TEXT NOT NULL, + paid_at TEXT +)`) + if err != nil { + panic(err) + } + _, err = g.DB().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_payment_order_phone ON payment_order(phone_num)") + if err != nil { + panic(err) + } +} + +// InsertOneInTx 事务内插入订单 +func (d *paymentOrderDao) InsertOneInTx(ctx context.Context, tx gdb.TX, m *entity.PaymentOrder) error { + _, err := g.DB().Model(consts.TablePaymentOrder).Ctx(ctx).TX(tx).Insert(m) + return err +} + +// GetByPk 按订单号查询(普通读,不走缓存) +func (d *paymentOrderDao) GetByPk(ctx context.Context, orderId string) (*entity.PaymentOrder, error) { + return d.getByPk(ctx, nil, orderId) +} + +// GetByPkInTx 事务内按订单号查询 +func (d *paymentOrderDao) GetByPkInTx(ctx context.Context, tx gdb.TX, orderId string) (*entity.PaymentOrder, error) { + return d.getByPk(ctx, tx, orderId) +} + +func (d *paymentOrderDao) getByPk(ctx context.Context, tx gdb.TX, orderId string) (*entity.PaymentOrder, error) { + m := g.DB().Model(consts.TablePaymentOrder).Ctx(ctx) + if tx != nil { + m = m.TX(tx) + } + var e entity.PaymentOrder + if err := m.Where("order_id", orderId).Scan(&e); err != nil { + if common.IsNoRows(err) { + return nil, nil + } + return nil, err + } + return &e, nil +} + +// UpdatePaidWechatInTx 事务内标记微信订单已支付(状态机校验在 service 完成) +func (d *paymentOrderDao) UpdatePaidWechatInTx(ctx context.Context, tx gdb.TX, orderId, wxTradeNo string) error { + _, err := g.DB().Model(consts.TablePaymentOrder).Ctx(ctx).TX(tx). + Where("order_id", orderId). + Data(gdb.Map{"status": consts.OrderStatusPaid, "wx_trade_no": wxTradeNo, "paid_at": gtime.Now()}). + Update() + return err +} + +// UpdatePaidAlipayInTx 事务内标记支付宝订单已支付 +func (d *paymentOrderDao) UpdatePaidAlipayInTx(ctx context.Context, tx gdb.TX, orderId, alipayTradeNo string) error { + _, err := g.DB().Model(consts.TablePaymentOrder).Ctx(ctx).TX(tx). + Where("order_id", orderId). + Data(gdb.Map{"status": consts.OrderStatusPaid, "alipay_trade_no": alipayTradeNo, "paid_at": gtime.Now()}). + Update() + return err +} + +// UpdateClosedInTx 事务内关闭订单(渠道下单失败回滚 / 惰性回收) +func (d *paymentOrderDao) UpdateClosedInTx(ctx context.Context, tx gdb.TX, orderId string) error { + _, err := g.DB().Model(consts.TablePaymentOrder).Ctx(ctx).TX(tx). + Where("order_id", orderId). + Data(gdb.Map{"status": consts.OrderStatusClosed}). + Update() + return err +} + +// CloseStaleCreatedInTx 惰性关闭账号下超时未支付的 created 订单 +func (d *paymentOrderDao) CloseStaleCreatedInTx(ctx context.Context, tx gdb.TX, phoneNum string, before *gtime.Time) error { + _, err := g.DB().Model(consts.TablePaymentOrder).Ctx(ctx).TX(tx). + Where("phone_num", phoneNum). + Where("status", consts.OrderStatusCreated). + WhereLT("created_at", before). + Data(gdb.Map{"status": consts.OrderStatusClosed}). + Update() + return err +} + +// GetRecentCreatedInTx 事务内查账号在 since 之后创建的未支付订单(防重复扣款兜底,返回最早一条) +func (d *paymentOrderDao) GetRecentCreatedInTx(ctx context.Context, tx gdb.TX, phoneNum string, since *gtime.Time) (*entity.PaymentOrder, error) { + var e entity.PaymentOrder + err := g.DB().Model(consts.TablePaymentOrder).Ctx(ctx).TX(tx). + Where("phone_num", phoneNum). + Where("status", consts.OrderStatusCreated). + WhereGTE("created_at", since). + OrderAsc("created_at"). + Limit(1). + Scan(&e) + if err != nil { + if common.IsNoRows(err) { + return nil, nil + } + return nil, err + } + return &e, nil +} + +// PageByFilter 管理端订单分页查询:按条件精确筛选(phoneNum/status 精确、时间区间含界), +// 写频繁表不走缓存;返回同条件总数与当前页列表(按下单时间倒序)。 +func (d *paymentOrderDao) PageByFilter(ctx context.Context, phoneNum, status string, + startAt, endAt *gtime.Time, page, size int) ([]*entity.PaymentOrder, int64, error) { + base := func() *gdb.Model { + m := g.DB().Model(consts.TablePaymentOrder).Ctx(ctx) + if phoneNum != "" { + m = m.Where("phone_num", phoneNum) + } + if status != "" { + m = m.Where("status", status) + } + if startAt != nil { + m = m.WhereGTE("created_at", startAt) + } + if endAt != nil { + m = m.WhereLTE("created_at", endAt) + } + return m + } + total, err := base().Count() + if err != nil { + return nil, 0, err + } + var list []*entity.PaymentOrder + err = base().OrderDesc("created_at").Limit((page-1)*size, size).Scan(&list) + if err != nil { + if common.IsNoRows(err) { + return []*entity.PaymentOrder{}, int64(total), nil + } + return nil, 0, err + } + return list, int64(total), nil +} diff --git a/server/biz/model/dto/admin.go b/server/biz/model/dto/admin.go new file mode 100644 index 0000000..8d04602 --- /dev/null +++ b/server/biz/model/dto/admin.go @@ -0,0 +1,81 @@ +package dto + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// 后台管理端接口聚合(组前缀 /api/v1/admin,path 相对组)。 +// service 按表归入各表文件(订单→order、授权→license),controller 聚合 admin。 + +// ---------- 订单管理 ---------- + +// AdminOrderListReq 订单列表查询 +type AdminOrderListReq struct { + g.Meta `path:"/orders" method:"get" summary:"订单列表" tags:"管理端"` + PhoneNum string `json:"phoneNum" v:"length:0,20" dc:"手机号精确匹配"` + Status string `json:"status" v:"in:created,paid,closed" dc:"订单状态"` + StartAt *gtime.Time `json:"startAt" dc:"下单时间起(含)"` + EndAt *gtime.Time `json:"endAt" dc:"下单时间止(含)"` + Page int `json:"page" v:"integer|min:1" dc:"页码,默认 1"` + Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"` +} + +// AdminOrderItem 订单条目(金额整数分,前端展示 ÷100) +type AdminOrderItem struct { + OrderId string `json:"orderId"` + PhoneNum string `json:"phoneNum"` + PlanId string `json:"planId"` + Channel string `json:"channel"` + AmountCents int64 `json:"amountCents"` + Status string `json:"status"` + CreatedAt *gtime.Time `json:"createdAt"` + PaidAt *gtime.Time `json:"paidAt"` +} + +type AdminOrderListRes struct { + Total int64 `json:"total"` + List []*AdminOrderItem `json:"list"` +} + +// ---------- 授权管理 ---------- + +// AdminLicenseListReq 账号/授权列表查询 +type AdminLicenseListReq struct { + g.Meta `path:"/licenses" method:"get" summary:"账号列表" tags:"管理端"` + PhoneNum string `json:"phoneNum" v:"length:0,20" dc:"手机号模糊匹配"` + Page int `json:"page" v:"integer|min:1" dc:"页码,默认 1"` + Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"` +} + +// AdminLicenseItem 账号条目(未充值时 expiresAt 为 null) +type AdminLicenseItem struct { + PhoneNum string `json:"phoneNum"` + ExpiresAt *gtime.Time `json:"expiresAt"` + CreatedAt *gtime.Time `json:"createdAt"` + UpdatedAt *gtime.Time `json:"updatedAt"` +} + +type AdminLicenseListRes struct { + Total int64 `json:"total"` + List []*AdminLicenseItem `json:"list"` +} + +// AdminGrantReq 手动授权(免费发卡,与支付回调同自然日叠加语义;未注册账号自动建占位行) +type AdminGrantReq struct { + g.Meta `path:"/licenses/grant" method:"post" summary:"手动授权" tags:"管理端"` + PhoneNum string `json:"phoneNum" v:"required|length:6,20" dc:"手机号"` + PlanId string `json:"planId" v:"required" dc:"套餐标识(需在 config.yml plans 存在)"` +} + +type AdminGrantRes struct { + ExpiresAt *gtime.Time `json:"expiresAt"` +} + +// AdminRevokeReq 撤销授权(清授权保留账号) +type AdminRevokeReq struct { + g.Meta `path:"/licenses/revoke" method:"post" summary:"撤销授权" tags:"管理端"` + PhoneNum string `json:"phoneNum" v:"required|length:6,20" dc:"手机号"` +} + +type AdminRevokeRes struct{} diff --git a/server/biz/model/dto/auth.go b/server/biz/model/dto/auth.go new file mode 100644 index 0000000..26f9ce0 --- /dev/null +++ b/server/biz/model/dto/auth.go @@ -0,0 +1,24 @@ +package dto + +import "github.com/gogf/gf/v2/frame/g" + +// RegisterReq 手机号注册(公开接口,无需登录) +type RegisterReq struct { + g.Meta `path:"/auth/register" method:"post" summary:"注册" tags:"账号"` + Phone string `json:"phone" v:"required|regex:^1[3-9]\\d{9}$" dc:"手机号"` + Password string `json:"password" v:"required|length:6,64" dc:"密码"` +} + +type RegisterRes struct{} + +// LoginReq 手机号登录(公开接口,无需登录) +type LoginReq struct { + g.Meta `path:"/auth/login" method:"post" summary:"登录" tags:"账号"` + Phone string `json:"phone" v:"required|regex:^1[3-9]\\d{9}$" dc:"手机号"` + Password string `json:"password" v:"required|length:6,64" dc:"密码"` +} + +// LoginRes 登录成功签发 token,后续登录态接口携带 Authorization: Bearer +type LoginRes struct { + Token string `json:"token"` +} diff --git a/server/biz/model/dto/license.go b/server/biz/model/dto/license.go new file mode 100644 index 0000000..ce5f43a --- /dev/null +++ b/server/biz/model/dto/license.go @@ -0,0 +1,17 @@ +package dto + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// LicenseReq 查询授权(登录态:手机号由 AuthRequired 从 token 解出) +type LicenseReq struct { + g.Meta `path:"/license" method:"get" summary:"查询授权" tags:"授权"` +} + +// LicenseRes 授权状态:active=false 时 expiresAt 为 null +type LicenseRes struct { + Active bool `json:"active"` + ExpiresAt *gtime.Time `json:"expiresAt"` +} diff --git a/server/biz/model/dto/order.go b/server/biz/model/dto/order.go new file mode 100644 index 0000000..7c87136 --- /dev/null +++ b/server/biz/model/dto/order.go @@ -0,0 +1,54 @@ +package dto + +import "github.com/gogf/gf/v2/frame/g" + +// ListPlansReq 套餐列表(登录态;价格以服务端 config.yml plans 为准) +type ListPlansReq struct { + g.Meta `path:"/plans" method:"get" summary:"套餐列表" tags:"订单"` +} + +// PlanItem 套餐项:展示名由客户端按 days 派生"N天" +type PlanItem struct { + PlanId string `json:"planId"` + Days int `json:"days"` + PriceCents int64 `json:"priceCents"` +} + +type ListPlansRes struct { + List []*PlanItem `json:"list"` +} + +// CreateOrderReq 创建支付订单(登录态:手机号由 AuthRequired 从 token 解出) +type CreateOrderReq struct { + g.Meta `path:"/orders" method:"post" summary:"创建支付订单" tags:"订单"` + PlanId string `json:"planId" v:"required" dc:"套餐标识(需在 config.yml plans 存在)"` + Channel string `json:"channel" v:"required|in:wechat,alipay" dc:"支付渠道"` +} + +// CreateOrderRes 创建订单结果:payParams 按渠道二选一填充 +type CreateOrderRes struct { + OrderId string `json:"orderId"` + PayParams *PayParams `json:"payParams"` +} + +// PayParams 支付拉起参数:微信走 App 支付参数,支付宝走 orderStr +type PayParams struct { + PartnerId string `json:"partnerId,omitempty"` + PrepayId string `json:"prepayId,omitempty"` + NonceStr string `json:"nonceStr,omitempty"` + TimeStamp string `json:"timeStamp,omitempty"` + Sign string `json:"sign,omitempty"` + PackageValue string `json:"packageValue,omitempty"` + OrderStr string `json:"orderStr,omitempty"` +} + +// ConfirmOrderReq 确认支付结果(幂等,仅加速刷新,授权以回调为准) +type ConfirmOrderReq struct { + g.Meta `path:"/orders/{orderId}/confirm" method:"post" summary:"确认支付结果" tags:"订单"` + OrderId string `json:"orderId" v:"required" in:"path" dc:"订单号"` +} + +// ConfirmOrderRes 确认结果:paid | created | closed +type ConfirmOrderRes struct { + Status string `json:"status"` +} diff --git a/server/biz/model/dto/payment.go b/server/biz/model/dto/payment.go new file mode 100644 index 0000000..703abf2 --- /dev/null +++ b/server/biz/model/dto/payment.go @@ -0,0 +1,24 @@ +package dto + +import "github.com/gogf/gf/v2/frame/g" + +// WechatNotifyReq 微信支付回调:raw 请求头/体在 controller 经 g.RequestFromCtx 读取, +// dto 仅声明路由。 +type WechatNotifyReq struct { + g.Meta `path:"/wechat/notify" method:"post" summary:"微信支付回调" tags:"回调"` +} + +// WechatNotifyRes 微信回调应答:{"code":"SUCCESS"} 或 {"code":"FAIL","message":...} +type WechatNotifyRes struct { + Code string `json:"code"` + Message string `json:"message,omitempty"` +} + +// AlipayNotifyReq 支付宝回调:raw 表单在 controller 经 g.RequestFromCtx 读取, +// 应答按支付宝约定直接写 "success"/"failure" 文本。 +type AlipayNotifyReq struct { + g.Meta `path:"/alipay/notify" method:"post" summary:"支付宝回调" tags:"回调"` +} + +// AlipayNotifyRes 支付宝回调应答占位(响应体由 controller 直接写入,此处仅满足框架签名要求) +type AlipayNotifyRes struct{} diff --git a/server/biz/model/entity/license.go b/server/biz/model/entity/license.go new file mode 100644 index 0000000..c20372c --- /dev/null +++ b/server/biz/model/entity/license.go @@ -0,0 +1,13 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +// License 手机号账号与授权:一账号一记录;password 不对外(json 排除); +// expires_at 未充值(含被撤销)时为 NULL;购买的套餐记录在 payment_order,本表不存。 +type License struct { + PhoneNum string `json:"phoneNum" orm:"phone_num" description:"手机号账号(主键)"` + Password string `json:"-" orm:"password" description:"bcrypt 加盐哈希"` + ExpiresAt *gtime.Time `json:"expiresAt" orm:"expires_at" description:"到期时间(自然日,服务端时区),未充值 NULL"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"注册时间"` + UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"` +} diff --git a/server/biz/model/entity/payment_order.go b/server/biz/model/entity/payment_order.go new file mode 100644 index 0000000..65db128 --- /dev/null +++ b/server/biz/model/entity/payment_order.go @@ -0,0 +1,17 @@ +package entity + +import "github.com/gogf/gf/v2/os/gtime" + +// PaymentOrder 支付订单 +type PaymentOrder struct { + OrderId string `json:"orderId" orm:"order_id" description:"O+时间戳+随机后缀"` + PhoneNum string `json:"phoneNum" orm:"phone_num" description:"下单账号(手机号)"` + PlanId string `json:"planId" orm:"plan_id" description:"套餐标识"` + Channel string `json:"channel" orm:"channel" description:"wechat | alipay"` + AmountCents int64 `json:"amountCents" orm:"amount_cents" description:"下单时锁定的金额(整数分)"` + Status string `json:"status" orm:"status" description:"created | paid | closed"` + WxTradeNo *string `json:"wxTradeNo" orm:"wx_trade_no" description:"微信交易号(幂等唯一),未支付为 NULL"` + AlipayTradeNo *string `json:"alipayTradeNo" orm:"alipay_trade_no" description:"支付宝交易号(幂等唯一),未支付为 NULL"` + CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"创建时间"` + PaidAt *gtime.Time `json:"paidAt" orm:"paid_at" description:"支付成功时间"` +} diff --git a/server/biz/service/admin_test.go b/server/biz/service/admin_test.go new file mode 100644 index 0000000..9371030 --- /dev/null +++ b/server/biz/service/admin_test.go @@ -0,0 +1,211 @@ +package service + +// 管理端白盒测试:手动授权/撤销、订单分页、套餐更新、账号注册/登录。 +// 运行方式同 payment_test.go:cd server && GF_GCFG_FILE=biz/service/testdata/config.yml go test ./biz/service/ + +import ( + "strconv" + "testing" + "time" + + "github.com/gogf/gf/v2/os/gtime" + + "observer-server/biz/consts" + "observer-server/biz/dao" + "observer-server/biz/model/dto" + "observer-server/biz/model/entity" + "observer-server/common" +) + +func startOfToday() time.Time { + now := time.Now() + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local) +} + +// TestAuthRegisterLogin 注册/登录:注册后可登录、密码错误拒绝、重复注册报错 +func TestAuthRegisterLogin(t *testing.T) { + phone := uniquePhone() + if _, err := Auth.Register(ctx(), &dto.RegisterReq{Phone: phone, Password: "pass123456"}); err != nil { + t.Fatalf("register: %v", err) + } + // 登录成功,token 可解析回手机号 + res, err := Auth.Login(ctx(), &dto.LoginReq{Phone: phone, Password: "pass123456"}) + if err != nil { + t.Fatalf("login: %v", err) + } + got, err := common.ParseToken(ctx(), res.Token) + if err != nil || got != phone { + t.Fatalf("parse token = %q, %v; want %q", got, err, phone) + } + // 密码错误 + if _, err := Auth.Login(ctx(), &dto.LoginReq{Phone: phone, Password: "wrong"}); err == nil { + t.Fatal("wrong password should fail") + } + // 重复注册 + if _, err := Auth.Register(ctx(), &dto.RegisterReq{Phone: phone, Password: "other123"}); err == nil { + t.Fatal("duplicate register should fail") + } + // 未注册手机号登录失败 + if _, err := Auth.Login(ctx(), &dto.LoginReq{Phone: uniquePhone(), Password: "pass123456"}); err == nil { + t.Fatal("login unregistered phone should fail") + } +} + +// TestAuthRegisterAfterGrant 运营先手动授权(占位行无密码)→ 用户注册补密码 → 登录成功 +func TestAuthRegisterAfterGrant(t *testing.T) { + phone := uniquePhone() + if _, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "day"}); err != nil { + t.Fatalf("grant: %v", err) + } + if _, err := Auth.Register(ctx(), &dto.RegisterReq{Phone: phone, Password: "pass123456"}); err != nil { + t.Fatalf("register after grant: %v", err) + } + if _, err := Auth.Login(ctx(), &dto.LoginReq{Phone: phone, Password: "pass123456"}); err != nil { + t.Fatalf("login after register: %v", err) + } + // 注册补密码不能清掉已授权 + lic, err := dao.License.GetByPhone(ctx(), phone) + if err != nil || lic == nil || lic.ExpiresAt == nil { + t.Fatalf("grant lost after register: %+v, %v", lic, err) + } +} + +// TestAdminGrant 手动授权:新账号从今天 0 点起按套餐天数累计 +func TestAdminGrant(t *testing.T) { + phone := uniquePhone() + res, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "week"}) + if err != nil { + t.Fatalf("grant: %v", err) + } + want := startOfToday().AddDate(0, 0, 7) + if !res.ExpiresAt.Time.Equal(want) { + t.Fatalf("grant expiresAt = %s, want %s", res.ExpiresAt, want) + } + lic, err := License.Get(common.WithPhone(ctx(), phone), &dto.LicenseReq{}) + if err != nil { + t.Fatal(err) + } + if !lic.Active { + t.Fatalf("license res = %+v", lic) + } +} + +// TestAdminGrantRenewal 手动授权续期叠加:未过期顺延;无效套餐报错 +func TestAdminGrantRenewal(t *testing.T) { + phone := uniquePhone() + if _, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "week"}); err != nil { + t.Fatalf("grant 1: %v", err) + } + lic, _ := dao.License.GetByPhone(ctx(), phone) + + res, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "month"}) + if err != nil { + t.Fatalf("grant 2: %v", err) + } + want := lic.ExpiresAt.Time.AddDate(0, 0, 30) + if !res.ExpiresAt.Time.Equal(want) { + t.Fatalf("renewal expiresAt = %s, want %s (base %s)", res.ExpiresAt, want, lic.ExpiresAt) + } + + if _, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "nope"}); err == nil { + t.Fatal("invalid plan should fail") + } +} + +// TestAdminRevoke 撤销授权:清授权字段保留账号(可继续登录),授权查询立即 inactive +func TestAdminRevoke(t *testing.T) { + phone := uniquePhone() + if _, err := Auth.Register(ctx(), &dto.RegisterReq{Phone: phone, Password: "pass123456"}); err != nil { + t.Fatalf("register: %v", err) + } + if _, err := License.AdminGrant(ctx(), &dto.AdminGrantReq{PhoneNum: phone, PlanId: "day"}); err != nil { + t.Fatalf("grant: %v", err) + } + if _, err := License.AdminRevoke(ctx(), &dto.AdminRevokeReq{PhoneNum: phone}); err != nil { + t.Fatalf("revoke: %v", err) + } + lic, err := License.Get(common.WithPhone(ctx(), phone), &dto.LicenseReq{}) + if err != nil { + t.Fatal(err) + } + if lic.Active { + t.Fatal("license should be inactive after revoke") + } + row, err := dao.License.GetByPhone(ctx(), phone) + if err != nil { + t.Fatal(err) + } + if row == nil || row.ExpiresAt != nil { + t.Fatalf("account row should be kept with auth cleared: %+v", row) + } + // 账号仍可登录(密码未删) + if _, err := Auth.Login(ctx(), &dto.LoginReq{Phone: phone, Password: "pass123456"}); err != nil { + t.Fatalf("login after revoke: %v", err) + } +} + +// TestAdminListOrders 订单分页与筛选:总数、渠道筛选、分页截断 +func TestAdminListOrders(t *testing.T) { + phone := uniquePhone() + orderId := uniqueOrderId("O-list-2-") + for i, channel := range []string{consts.ChannelWechat, consts.ChannelWechat, consts.ChannelAlipay} { + insertOrder(t, &entity.PaymentOrder{ + OrderId: uniqueOrderId("O-list-" + strconv.Itoa(i) + "-"), PhoneNum: phone, PlanId: "day", Channel: channel, + AmountCents: 1000, Status: consts.OrderStatusCreated, CreatedAt: gtime.Now(), + }) + } + insertOrder(t, &entity.PaymentOrder{ + OrderId: orderId, PhoneNum: phone, PlanId: "day", Channel: consts.ChannelWechat, + AmountCents: 1000, Status: consts.OrderStatusCreated, CreatedAt: gtime.Now(), + }) + + res, err := Order.AdminListOrders(ctx(), &dto.AdminOrderListReq{PhoneNum: phone, Size: 2}) + if err != nil { + t.Fatalf("list orders: %v", err) + } + if res.Total != 4 || len(res.List) != 2 { + t.Fatalf("total=%d len=%d, want 4/2", res.Total, len(res.List)) + } + + wx, err := Order.AdminListOrders(ctx(), &dto.AdminOrderListReq{PhoneNum: phone, Channel: consts.ChannelWechat, Size: 10}) + if err != nil { + t.Fatal(err) + } + if wx.Total != 3 { + t.Fatalf("wechat total = %d, want 3", wx.Total) + } + + one, err := Order.AdminListOrders(ctx(), &dto.AdminOrderListReq{OrderId: orderId}) + if err != nil { + t.Fatal(err) + } + if one.Total != 1 || len(one.List) != 1 || one.List[0].OrderId != orderId { + t.Fatalf("filter by orderId = %+v", one) + } + if one.List[0].AmountCents != 1000 { + t.Fatalf("amountCents = %d, want 1000", one.List[0].AmountCents) + } + if one.List[0].PhoneNum != phone { + t.Fatalf("phoneNum = %s, want %s", one.List[0].PhoneNum, phone) + } +} + +// TestListPlans 套餐列表:来自 testdata/config.yml plans 节点(配置驱动,无数据库表) +func TestListPlans(t *testing.T) { + res, err := Order.ListPlans(ctx(), &dto.ListPlansReq{}) + if err != nil { + t.Fatalf("list plans: %v", err) + } + if len(res.List) != 3 { + t.Fatalf("plans = %d, want 3", len(res.List)) + } + var week *dto.PlanItem + for _, p := range res.List { + if p.PlanId == "week" { + week = p + } + } + if week == nil || week.Days != 7 || week.PriceCents != 5600 { + t.Fatalf("week plan = %+v, want days=7 priceCents=5600", week) + } +} diff --git a/server/biz/service/auth.go b/server/biz/service/auth.go new file mode 100644 index 0000000..50b091c --- /dev/null +++ b/server/biz/service/auth.go @@ -0,0 +1,66 @@ +package service + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "golang.org/x/crypto/bcrypt" + + "observer-server/biz/dao" + "observer-server/biz/model/dto" + "observer-server/common" +) + +// authService 账号业务:注册(license 表即账号表,注册即插行)、登录(bcrypt 校验 + 签发 token) +type authService struct{} + +var Auth = &authService{} + +// Register 注册:已注册(有密码)报错;存在运营手动授权先建的占位行(无密码)时补写密码完成注册。 +func (s *authService) Register(ctx context.Context, req *dto.RegisterReq) (*dto.RegisterRes, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, gerror.Wrap(err, "密码加密失败") + } + err = common.Serial().Submit(ctx, func() error { + return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + lic, err := dao.License.GetByPhoneInTx(ctx, tx, req.Phone) + if err != nil { + return err + } + if lic == nil { + return dao.License.Register(ctx, tx, req.Phone, string(hash)) + } + if lic.Password == "" { + return dao.License.SetPasswordInTx(ctx, tx, req.Phone, string(hash)) + } + return gerror.New("该手机号已注册") + }) + }) + if err != nil { + return nil, err + } + common.ClearCache(ctx, "license:"+req.Phone) + return &dto.RegisterRes{}, nil +} + +// Login 登录:绕缓存查账号(密码变更即时生效)→ bcrypt 比对 → 签发 token +func (s *authService) Login(ctx context.Context, req *dto.LoginReq) (*dto.LoginRes, error) { + lic, err := dao.License.GetByPhoneNoCache(ctx, req.Phone) + if err != nil { + return nil, err + } + if lic == nil || lic.Password == "" { + return nil, gerror.New("账号或密码错误") + } + if err := bcrypt.CompareHashAndPassword([]byte(lic.Password), []byte(req.Password)); err != nil { + return nil, gerror.New("账号或密码错误") + } + token, err := common.SignToken(ctx, req.Phone) + if err != nil { + return nil, err + } + return &dto.LoginRes{Token: token}, nil +} diff --git a/server/biz/service/license.go b/server/biz/service/license.go new file mode 100644 index 0000000..d67c7cd --- /dev/null +++ b/server/biz/service/license.go @@ -0,0 +1,104 @@ +package service + +import ( + "context" + "time" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + "observer-server/biz/dao" + "observer-server/biz/model/dto" + "observer-server/common" +) + +// licenseService 授权业务:查询(缓存)、管理端手动授权/撤销(单写者串行事务) +type licenseService struct{} + +var License = &licenseService{} + +// Get 查询授权:expires_at > now 视为 active(未充值 NULL 即未授权); +// 手机号由登录中间件从 token 解出;查询走缓存(落授权后已清缓存)。 +func (s *licenseService) Get(ctx context.Context, req *dto.LicenseReq) (*dto.LicenseRes, error) { + lic, err := dao.License.GetByPhone(ctx, common.PhoneFromCtx(ctx)) + if err != nil { + return nil, err + } + if lic == nil || lic.ExpiresAt == nil || !lic.ExpiresAt.Time.After(time.Now()) { + return &dto.LicenseRes{Active: false}, nil + } + return &dto.LicenseRes{Active: true, ExpiresAt: lic.ExpiresAt}, nil +} + +// CalcExpiresAt 计算自然日到期时间:base 所在日 0 点 + days 天(day 套餐即当天 24:00 失效)。 +// 续费叠加由调用方传入 base = max(现有到期, now),本方法不感知现有授权。 +func (s *licenseService) CalcExpiresAt(base time.Time, days int) *gtime.Time { + start := time.Date(base.Year(), base.Month(), base.Day(), 0, 0, 0, 0, base.Location()) + return gtime.New(start.AddDate(0, 0, days)) +} + +// AdminListLicenses 管理端账号/授权分页列表(含未充值账号) +func (s *licenseService) AdminListLicenses(ctx context.Context, req *dto.AdminLicenseListReq) (*dto.AdminLicenseListRes, error) { + page, size := common.NormalizePage(req.Page, req.Size) + list, total, err := dao.License.PageByPhone(ctx, req.PhoneNum, page, size) + if err != nil { + return nil, err + } + items := make([]*dto.AdminLicenseItem, 0, len(list)) + for _, lic := range list { + items = append(items, &dto.AdminLicenseItem{ + PhoneNum: lic.PhoneNum, + ExpiresAt: lic.ExpiresAt, CreatedAt: lic.CreatedAt, UpdatedAt: lic.UpdatedAt, + }) + } + return &dto.AdminLicenseListRes{Total: total, List: items}, nil +} + +// AdminGrant 手动授权(免费发卡):与支付回调 persistPaid 同语义——单写者串行事务内 +// base = max(现有到期, now) 后按自然日叠加,已过期账号从当前时间起算; +// 未注册账号自动建占位行(无密码,用户注册时补密码);提交后清授权缓存。 +func (s *licenseService) AdminGrant(ctx context.Context, req *dto.AdminGrantReq) (*dto.AdminGrantRes, error) { + plan, err := common.GetPlan(ctx, req.PlanId) + if err != nil { + return nil, err + } + if plan == nil { + return nil, gerror.NewCode(common.CodePlanNotConfigured, "套餐不存在") + } + var expiresAt *gtime.Time + err = common.Serial().Submit(ctx, func() error { + return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + lic, err := dao.License.GetByPhoneInTx(ctx, tx, req.PhoneNum) + if err != nil { + return err + } + base := time.Now() + if lic != nil && lic.ExpiresAt != nil && lic.ExpiresAt.Time.After(base) { + base = lic.ExpiresAt.Time + } + expiresAt = License.CalcExpiresAt(base, plan.Days) + return dao.License.UpsertAuthInTx(ctx, tx, req.PhoneNum, expiresAt, gtime.Now()) + }) + }) + if err != nil { + return nil, err + } + common.ClearCache(ctx, "license:"+req.PhoneNum) + return &dto.AdminGrantRes{ExpiresAt: expiresAt}, nil +} + +// AdminRevoke 撤销授权:清空授权字段、保留账号行(密码不动),提交后清缓存。 +func (s *licenseService) AdminRevoke(ctx context.Context, req *dto.AdminRevokeReq) (*dto.AdminRevokeRes, error) { + err := common.Serial().Submit(ctx, func() error { + return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + return dao.License.ClearAuthInTx(ctx, tx, req.PhoneNum) + }) + }) + if err != nil { + return nil, err + } + common.ClearCache(ctx, "license:"+req.PhoneNum) + return &dto.AdminRevokeRes{}, nil +} diff --git a/server/biz/service/order.go b/server/biz/service/order.go new file mode 100644 index 0000000..c4aa7d6 --- /dev/null +++ b/server/biz/service/order.go @@ -0,0 +1,161 @@ +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + "observer-server/biz/consts" + "observer-server/biz/dao" + "observer-server/biz/model/dto" + "observer-server/biz/model/entity" + "observer-server/common" +) + +// orderService 订单业务:创建(惰性回收+复用+渠道下单)、确认(幂等,回调为唯一授权来源) +type orderService struct{} + +var Order = &orderService{} + +// ListPlans 套餐列表(config.yml plans 静态定价,客户端不硬编码价格) +func (s *orderService) ListPlans(ctx context.Context, req *dto.ListPlansReq) (*dto.ListPlansRes, error) { + plans, err := common.ListPlans(ctx) + if err != nil { + return nil, err + } + items := make([]*dto.PlanItem, 0, len(plans)) + for _, p := range plans { + items = append(items, &dto.PlanItem{ + PlanId: p.Id, Days: p.Days, PriceCents: p.PriceCents, + }) + } + return &dto.ListPlansRes{List: items}, nil +} + +// Create 创建支付订单: +// 1. 校验套餐(服务端锁价,金额不由客户端传入)与渠道配置 +// 2. 单写者串行事务:惰性关闭超时未支付订单 → 2 小时内复用 created 订单(防重复扣款)→ 新建订单 +// 3. 渠道统一下单(外部 IO,串行外执行),失败回滚关闭订单 +func (s *orderService) Create(ctx context.Context, req *dto.CreateOrderReq) (*dto.CreateOrderRes, error) { + plan, err := common.GetPlan(ctx, req.PlanId) + if err != nil { + return nil, err + } + if plan == nil { + return nil, gerror.NewCode(common.CodePlanNotConfigured, "套餐不存在") + } + if err := Payment.ensureChannelConfigured(ctx, req.Channel); err != nil { + return nil, err + } + + phoneNum := common.PhoneFromCtx(ctx) + var order *entity.PaymentOrder + err = common.Serial().Submit(ctx, func() error { + return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + // 惰性关闭超时未支付订单(对齐微信支付 2 小时有效期,保证支付成功后回调必然可落授权) + if err := dao.PaymentOrder.CloseStaleCreatedInTx(ctx, tx, phoneNum, + gtime.Now().Add(-consts.PendingOrderWindowSeconds*time.Second)); err != nil { + return err + } + // 同账号窗口内 created 订单直接复用,禁止重复创建 + recent, err := dao.PaymentOrder.GetRecentCreatedInTx(ctx, tx, phoneNum, + gtime.Now().Add(-consts.PendingOrderWindowSeconds*time.Second)) + if err != nil { + return err + } + if recent != nil { + order = recent + return nil + } + order = &entity.PaymentOrder{ + OrderId: genOrderId(), + PhoneNum: phoneNum, + PlanId: req.PlanId, + Channel: req.Channel, + AmountCents: plan.PriceCents, + Status: consts.OrderStatusCreated, + CreatedAt: gtime.Now(), + } + return dao.PaymentOrder.InsertOneInTx(ctx, tx, order) + }) + }) + if err != nil { + return nil, err + } + + // 渠道统一下单(外部 IO 不占串行写者) + payParams, err := Payment.CreatePayParams(ctx, order, fmt.Sprintf("%d天", plan.Days), req.Channel) + if err != nil { + if closeErr := common.Serial().Submit(ctx, func() error { + return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + return dao.PaymentOrder.UpdateClosedInTx(ctx, tx, order.OrderId) + }) + }); closeErr != nil { + g.Log().Errorf(ctx, "渠道下单失败回滚关闭订单 %s 失败: %+v", order.OrderId, closeErr) + } + return nil, err + } + return &dto.CreateOrderRes{OrderId: order.OrderId, PayParams: payParams}, nil +} + +// Confirm 确认支付结果(幂等):只读订单状态,回调未到账返回 created; +// 与回调写链路同经单写者串行,避免读到写中脏状态。 +func (s *orderService) Confirm(ctx context.Context, req *dto.ConfirmOrderReq) (*dto.ConfirmOrderRes, error) { + var status string + err := common.Serial().Submit(ctx, func() error { + o, err := dao.PaymentOrder.GetByPk(ctx, req.OrderId) + if err != nil { + return err + } + if o == nil { + return gerror.NewCode(common.CodeOrderNotFound, "订单不存在") + } + switch o.Status { + case consts.OrderStatusPaid: + status = "paid" + case consts.OrderStatusCreated: + status = "created" + default: + status = "closed" + } + return nil + }) + if err != nil { + return nil, err + } + return &dto.ConfirmOrderRes{Status: status}, nil +} + +// AdminListOrders 管理端订单分页查询:金额原样整数分返回(展示转换在前端) +func (s *orderService) AdminListOrders(ctx context.Context, req *dto.AdminOrderListReq) (*dto.AdminOrderListRes, error) { + page, size := common.NormalizePage(req.Page, req.Size) + list, total, err := dao.PaymentOrder.PageByFilter(ctx, req.PhoneNum, + req.Status, req.StartAt, req.EndAt, page, size) + if err != nil { + return nil, err + } + items := make([]*dto.AdminOrderItem, 0, len(list)) + for _, o := range list { + items = append(items, &dto.AdminOrderItem{ + OrderId: o.OrderId, PhoneNum: o.PhoneNum, PlanId: o.PlanId, Channel: o.Channel, + AmountCents: o.AmountCents, Status: o.Status, CreatedAt: o.CreatedAt, PaidAt: o.PaidAt, + }) + } + return &dto.AdminOrderListRes{Total: total, List: items}, nil +} + +// genOrderId 订单号:O + 时间戳 + 随机后缀,长度 ≥ 20 不可枚举 +func genOrderId() string { + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + return fmt.Sprintf("O%d", time.Now().UnixNano()) + } + return fmt.Sprintf("O%s%s", time.Now().Format("20060102150405"), hex.EncodeToString(buf)) +} diff --git a/server/biz/service/payment.go b/server/biz/service/payment.go new file mode 100644 index 0000000..19f9160 --- /dev/null +++ b/server/biz/service/payment.go @@ -0,0 +1,380 @@ +package service + +import ( + "bytes" + "context" + "fmt" + "math" + "net/http" + "net/url" + "os" + "strconv" + "sync" + "time" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + "github.com/smartwalle/alipay/v3" + "github.com/wechatpay-apiv3/wechatpay-go/core" + "github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers" + "github.com/wechatpay-apiv3/wechatpay-go/core/downloader" + "github.com/wechatpay-apiv3/wechatpay-go/core/notify" + "github.com/wechatpay-apiv3/wechatpay-go/core/option" + "github.com/wechatpay-apiv3/wechatpay-go/services/payments" + "github.com/wechatpay-apiv3/wechatpay-go/services/payments/app" + "github.com/wechatpay-apiv3/wechatpay-go/utils" + + "observer-server/biz/consts" + "observer-server/biz/dao" + "observer-server/biz/model/dto" + "observer-server/biz/model/entity" + "observer-server/common" +) + +// paymentService 支付渠道接入:统一下单、回调验签落授权(回调是唯一授权来源,confirm 仅加速刷新) +type paymentService struct{} + +var Payment = &paymentService{} + +// ensureChannelConfigured 渠道未配置时拒绝下单(避免产生无法支付的订单) +func (s *paymentService) ensureChannelConfigured(ctx context.Context, channel string) error { + switch channel { + case consts.ChannelWechat: + if !wechatConf(ctx).configured() { + return gerror.NewCode(common.CodePaymentNotConfigured, "微信支付未配置") + } + case consts.ChannelAlipay: + if !alipayConf(ctx).configured() { + return gerror.NewCode(common.CodePaymentNotConfigured, "支付宝未配置") + } + } + return nil +} + +// CreatePayParams 渠道统一下单,返回客户端拉起支付参数 +func (s *paymentService) CreatePayParams(ctx context.Context, order *entity.PaymentOrder, planLabel, channel string) (*dto.PayParams, error) { + switch channel { + case consts.ChannelWechat: + return s.wechatPrepay(ctx, order, planLabel) + case consts.ChannelAlipay: + return s.alipayPrepay(ctx, order, planLabel) + } + return nil, gerror.Newf("未知支付渠道: %s", channel) +} + +// ---------- 微信支付(APIv3 APP 支付) ---------- + +type wechatConfig struct { + appid string + mchid string + apiV3Key string + serialNo string + privateKey string + notifyUrl string +} + +func wechatConf(ctx context.Context) wechatConfig { + return wechatConfig{ + appid: g.Cfg().MustGet(ctx, "payment.wechat.appid", "").String(), + mchid: g.Cfg().MustGet(ctx, "payment.wechat.mchid", "").String(), + apiV3Key: g.Cfg().MustGet(ctx, "payment.wechat.apiV3Key", "").String(), + serialNo: g.Cfg().MustGet(ctx, "payment.wechat.serialNo", "").String(), + privateKey: g.Cfg().MustGet(ctx, "payment.wechat.privateKey", "").String(), + notifyUrl: g.Cfg().MustGet(ctx, "payment.wechat.notifyUrl", "").String(), + } +} + +func (c wechatConfig) configured() bool { + return c.appid != "" && c.mchid != "" && c.apiV3Key != "" && c.serialNo != "" && + c.privateKey != "" && c.notifyUrl != "" +} + +var ( + wechatOnce sync.Once + wechatClient *core.Client + wechatNotifyHdl *notify.Handler + wechatErr error +) + +// wechatClientInstance 懒加载微信客户端与回调处理器(配置静态,进程内只初始化一次) +func wechatClientInstance() (*core.Client, *notify.Handler, error) { + wechatOnce.Do(func() { + ctx := context.Background() + cfg := wechatConf(ctx) + if !cfg.configured() { + wechatErr = gerror.NewCode(common.CodePaymentNotConfigured, "微信支付未配置") + return + } + key, err := utils.LoadPrivateKeyWithPath(cfg.privateKey) + if err != nil { + wechatErr = gerror.Wrap(err, "加载微信商户私钥失败") + return + } + client, err := core.NewClient(ctx, option.WithWechatPayAutoAuthCipher(cfg.mchid, cfg.serialNo, key, cfg.apiV3Key)) + if err != nil { + wechatErr = gerror.Wrap(err, "初始化微信支付客户端失败") + return + } + visitor := downloader.MgrInstance().GetCertificateVisitor(cfg.mchid) + handler, err := notify.NewRSANotifyHandler(cfg.apiV3Key, verifiers.NewSHA256WithRSAVerifier(visitor)) + if err != nil { + wechatErr = gerror.Wrap(err, "初始化微信回调处理器失败") + return + } + wechatClient, wechatNotifyHdl = client, handler + }) + return wechatClient, wechatNotifyHdl, wechatErr +} + +func (s *paymentService) wechatPrepay(ctx context.Context, order *entity.PaymentOrder, planLabel string) (*dto.PayParams, error) { + cfg := wechatConf(ctx) + client, _, err := wechatClientInstance() + if err != nil { + return nil, err + } + svc := app.AppApiService{Client: client} + resp, _, err := svc.PrepayWithRequestPayment(ctx, app.PrepayRequest{ + Appid: core.String(cfg.appid), + Mchid: core.String(cfg.mchid), + Description: core.String("视野会员-" + planLabel), + OutTradeNo: core.String(order.OrderId), + NotifyUrl: core.String(cfg.notifyUrl), + Amount: &app.Amount{Total: core.Int64(order.AmountCents)}, + }) + if err != nil { + return nil, gerror.Wrap(err, "微信统一下单失败") + } + return &dto.PayParams{ + PartnerId: strDeref(resp.PartnerId), + PrepayId: strDeref(resp.PrepayId), + NonceStr: strDeref(resp.NonceStr), + TimeStamp: strDeref(resp.TimeStamp), + Sign: strDeref(resp.Sign), + PackageValue: strDeref(resp.Package), + }, nil +} + +// WechatNotify 微信支付回调:APIv3 验签+解密 → 商户校验 → 落授权 +func (s *paymentService) WechatNotify(ctx context.Context, header http.Header, body []byte) error { + cfg := wechatConf(ctx) + if !cfg.configured() { + return gerror.NewCode(common.CodePaymentNotConfigured, "微信支付未配置") + } + _, handler, err := wechatClientInstance() + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "", bytes.NewReader(body)) + if err != nil { + return gerror.Wrap(err, "构造微信回调请求失败") + } + req.Header = header.Clone() + txn := new(payments.Transaction) + if _, err := handler.ParseNotifyRequest(ctx, req, txn); err != nil { + return gerror.NewCode(common.CodeCallbackVerifyFailed, "微信回调验签/解密失败: "+err.Error()) + } + // 商户校验:解密后校验 appid/mchid 与配置一致(防跨商户重放) + if txn.Appid == nil || *txn.Appid != cfg.appid || txn.Mchid == nil || *txn.Mchid != cfg.mchid { + return gerror.NewCode(common.CodeCallbackMismatch, "微信回调商户不匹配") + } + if txn.OutTradeNo == nil || txn.TransactionId == nil { + return gerror.Newf("微信回调缺少订单号") + } + return s.persistPaid(ctx, *txn.OutTradeNo, *txn.TransactionId, consts.ChannelWechat, 0) +} + +// ---------- 支付宝(APP 支付) ---------- + +type alipayConfig struct { + appid string + privateKey string + alipayPublicKey string + sellerId string + notifyUrl string +} + +func alipayConf(ctx context.Context) alipayConfig { + return alipayConfig{ + appid: g.Cfg().MustGet(ctx, "payment.alipay.appid", "").String(), + privateKey: g.Cfg().MustGet(ctx, "payment.alipay.privateKey", "").String(), + alipayPublicKey: g.Cfg().MustGet(ctx, "payment.alipay.alipayPublicKey", "").String(), + sellerId: g.Cfg().MustGet(ctx, "payment.alipay.sellerId", "").String(), + notifyUrl: g.Cfg().MustGet(ctx, "payment.alipay.notifyUrl", "").String(), + } +} + +func (c alipayConfig) configured() bool { + return c.appid != "" && c.privateKey != "" && c.alipayPublicKey != "" && + c.sellerId != "" && c.notifyUrl != "" +} + +var ( + alipayOnce sync.Once + alipayClient *alipay.Client + alipayErr error +) + +// alipayClientInstance 懒加载支付宝客户端(密钥文件路径 → PEM 内容) +func alipayClientInstance() (*alipay.Client, error) { + alipayOnce.Do(func() { + ctx := context.Background() + cfg := alipayConf(ctx) + if !cfg.configured() { + alipayErr = gerror.NewCode(common.CodePaymentNotConfigured, "支付宝未配置") + return + } + pri, err := os.ReadFile(cfg.privateKey) + if err != nil { + alipayErr = gerror.Wrap(err, "读取支付宝应用私钥失败") + return + } + client, err := alipay.New(cfg.appid, string(pri), true) + if err != nil { + alipayErr = gerror.Wrap(err, "初始化支付宝客户端失败") + return + } + pub, err := os.ReadFile(cfg.alipayPublicKey) + if err != nil { + alipayErr = gerror.Wrap(err, "读取支付宝公钥失败") + return + } + if err := client.LoadAliPayPublicKey(string(pub)); err != nil { + alipayErr = gerror.Wrap(err, "加载支付宝公钥失败") + return + } + alipayClient = client + }) + return alipayClient, alipayErr +} + +func (s *paymentService) alipayPrepay(ctx context.Context, order *entity.PaymentOrder, planLabel string) (*dto.PayParams, error) { + cfg := alipayConf(ctx) + client, err := alipayClientInstance() + if err != nil { + return nil, err + } + // 支付宝接口金额以「元」字符串传输(外部网关契约;内部存储与传输一律整数分) + pay := alipay.TradeAppPay{} + pay.Subject = "视野会员-" + planLabel + pay.OutTradeNo = order.OrderId + pay.TotalAmount = strconv.FormatFloat(float64(order.AmountCents)/100, 'f', 2, 64) + pay.ProductCode = "QUICK_MSECURITY_PAY" + pay.NotifyURL = cfg.notifyUrl + orderStr, err := client.TradeAppPay(pay) + if err != nil { + return nil, gerror.Wrap(err, "支付宝统一下单失败") + } + return &dto.PayParams{OrderStr: orderStr}, nil +} + +// AlipayNotify 支付宝回调:RSA2 验签 → 商户/金额校验 → 落授权 +func (s *paymentService) AlipayNotify(ctx context.Context, values url.Values) error { + cfg := alipayConf(ctx) + if !cfg.configured() { + return gerror.NewCode(common.CodePaymentNotConfigured, "支付宝未配置") + } + client, err := alipayClientInstance() + if err != nil { + return err + } + if err := client.VerifySign(ctx, values); err != nil { + return gerror.NewCode(common.CodeCallbackVerifyFailed, "支付宝回调验签失败: "+err.Error()) + } + // 商户校验:app_id/seller_id 与配置一致(防跨商户重放) + if values.Get("app_id") != cfg.appid || values.Get("seller_id") != cfg.sellerId { + return gerror.NewCode(common.CodeCallbackMismatch, "支付宝回调商户不匹配") + } + status := values.Get("trade_status") + if status != "TRADE_SUCCESS" && status != "TRADE_FINISHED" { + g.Log().Infof(ctx, "支付宝回调非终态 %s,忽略", status) + return nil + } + outTradeNo := values.Get("out_trade_no") + tradeNo := values.Get("trade_no") + if outTradeNo == "" || tradeNo == "" { + return gerror.Newf("支付宝回调缺少订单号") + } + amount, err := strconv.ParseFloat(values.Get("total_amount"), 64) + if err != nil { + return gerror.Newf("支付宝回调金额格式错误: %s", values.Get("total_amount")) + } + // 回调金额(元)转分后与订单快照比对 + cents := int64(math.Round(amount * 100)) + return s.persistPaid(ctx, outTradeNo, tradeNo, consts.ChannelAlipay, cents) +} + +// ---------- 回调落授权(唯一授权来源) ---------- + +// persistPaid 回调落授权:「查订单 → 算 expiresAt → 更新订单 → 写 license → 清缓存」, +// 整条链路在事务 + 单写者下串行执行(SQLite 无 WAL,串行同时保证 expiresAt 只按一次现授权 +// 状态计算,叠加语义不被并发重复累加)。 +// 幂等:订单已 paid 直接成功(重复回调忽略);closed 仅告警不落授权。 +func (s *paymentService) persistPaid(ctx context.Context, outTradeNo, tradeNo, channel string, amountCents int64) error { + var phoneNum string + err := common.Serial().Submit(ctx, func() error { + return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + o, err := dao.PaymentOrder.GetByPkInTx(ctx, tx, outTradeNo) + if err != nil { + return err + } + if o == nil { + return gerror.Newf("回调订单 %s 不存在", outTradeNo) + } + if o.Status == consts.OrderStatusPaid { + return nil // 幂等:重复回调直接成功 + } + if o.Status != consts.OrderStatusCreated { + g.Log().Errorf(ctx, "回调订单 %s 状态 %s,跳过落授权", outTradeNo, o.Status) + return nil + } + // 金额一致性校验(支付宝回调带金额;微信回调无金额字段、以微信侧为准) + if channel == consts.ChannelAlipay && amountCents != o.AmountCents { + return gerror.NewCode(common.CodeCallbackMismatch, + fmt.Sprintf("回调金额 %d 与订单金额 %d 不一致", amountCents, o.AmountCents)) + } + plan, err := common.GetPlan(ctx, o.PlanId) + if err != nil { + return err + } + if plan == nil { + return gerror.Newf("订单 %s 套餐 %s 不存在", outTradeNo, o.PlanId) + } + // 续费叠加:base = max(现有到期, 当前时间),已过期则从当前时间起算 + lic, err := dao.License.GetByPhoneInTx(ctx, tx, o.PhoneNum) + if err != nil { + return err + } + base := time.Now() + if lic != nil && lic.ExpiresAt != nil && lic.ExpiresAt.Time.After(base) { + base = lic.ExpiresAt.Time + } + expiresAt := License.CalcExpiresAt(base, plan.Days) + if channel == consts.ChannelWechat { + err = dao.PaymentOrder.UpdatePaidWechatInTx(ctx, tx, o.OrderId, tradeNo) + } else { + err = dao.PaymentOrder.UpdatePaidAlipayInTx(ctx, tx, o.OrderId, tradeNo) + } + if err != nil { + return err + } + phoneNum = o.PhoneNum + return dao.License.UpsertAuthInTx(ctx, tx, o.PhoneNum, expiresAt, gtime.Now()) + }) + }) + if err != nil { + return err + } + // 提交成功后清缓存(提交前清可能被并发授权查询以旧值回填) + common.ClearCache(ctx, "license:"+phoneNum) + return nil +} + +// strDeref 解指针(SDK 返回的 *string 字段可能为 nil) +func strDeref(p *string) string { + if p == nil { + return "" + } + return *p +} diff --git a/server/biz/service/payment_test.go b/server/biz/service/payment_test.go new file mode 100644 index 0000000..10c8921 --- /dev/null +++ b/server/biz/service/payment_test.go @@ -0,0 +1,248 @@ +package service + +// 服务层白盒测试:验证支付回调落授权核心链路(无真实商户,绕过 SDK 调用)。 +// 运行方式: cd server && GF_GCFG_FILE=biz/service/testdata/config.yml go test ./biz/service/ + +import ( + "context" + "strconv" + "testing" + "time" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + + "observer-server/biz/consts" + "observer-server/biz/dao" + "observer-server/biz/model/dto" + "observer-server/biz/model/entity" + "observer-server/common" +) + +func ctx() context.Context { + return context.Background() +} + +// uniquePhone / uniqueOrderId 每次运行生成唯一值,避免测试库残留数据冲突 +func uniquePhone() string { + return "138" + strconv.FormatInt(time.Now().UnixNano()%100000000, 10) +} + +func uniqueOrderId(prefix string) string { + return prefix + strconv.FormatInt(time.Now().UnixNano()%100000000, 10) +} + +// insertOrder 直插一条 created 订单(模拟下单链路产出) +func insertOrder(t *testing.T, o *entity.PaymentOrder) { + t.Helper() + err := g.DB().Transaction(ctx(), func(ctx context.Context, tx gdb.TX) error { + return dao.PaymentOrder.InsertOneInTx(ctx, tx, o) + }) + if err != nil { + t.Fatalf("insert order: %v", err) + } +} + +// TestPersistPaid 回调落授权:created → paid + 授权生效,重复回调幂等 +func TestPersistPaid(t *testing.T) { + phone := uniquePhone() + orderId := uniqueOrderId("O-test-persist-") + wxTrade := uniqueOrderId("wx-trade-") + insertOrder(t, &entity.PaymentOrder{ + OrderId: orderId, + PhoneNum: phone, + PlanId: "week", + Channel: consts.ChannelWechat, + AmountCents: 5600, + Status: consts.OrderStatusCreated, + CreatedAt: gtime.Now(), + }) + + if err := Payment.persistPaid(ctx(), orderId, wxTrade, consts.ChannelWechat, 0); err != nil { + t.Fatalf("persistPaid: %v", err) + } + + // 订单已支付 + o, err := dao.PaymentOrder.GetByPk(ctx(), orderId) + if err != nil { + t.Fatal(err) + } + if o.Status != consts.OrderStatusPaid || o.WxTradeNo == nil || *o.WxTradeNo != wxTrade { + t.Fatalf("order not paid: status=%s wx=%v", o.Status, o.WxTradeNo) + } + + // 授权生效:week = 今天 0 点 + 7 天 + lic, err := dao.License.GetByPhone(ctx(), phone) + if err != nil { + t.Fatal(err) + } + if lic == nil || lic.ExpiresAt == nil { + t.Fatal("license not granted") + } + want := time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, 7) + if !lic.ExpiresAt.Time.Equal(want) { + t.Fatalf("expiresAt = %s, want %s", lic.ExpiresAt.Time, want) + } + + // 授权查询接口返回 active + licRes, err := License.Get(common.WithPhone(ctx(), phone), &dto.LicenseReq{}) + if err != nil { + t.Fatal(err) + } + if !licRes.Active { + t.Fatalf("license res = %+v", licRes) + } + + // confirm 返回 paid + conf, err := Order.Confirm(ctx(), &dto.ConfirmOrderReq{OrderId: orderId}) + if err != nil { + t.Fatal(err) + } + if conf.Status != "paid" { + t.Fatalf("confirm status = %s, want paid", conf.Status) + } + + // 重复回调幂等:不报错、不重复累加授权 + before := lic.ExpiresAt + if err := Payment.persistPaid(ctx(), orderId, wxTrade, consts.ChannelWechat, 0); err != nil { + t.Fatalf("repeat persistPaid: %v", err) + } + lic2, _ := dao.License.GetByPhone(ctx(), phone) + if !lic2.ExpiresAt.Time.Equal(before.Time) { + t.Fatalf("idempotency broken: %s -> %s", before, lic2.ExpiresAt) + } +} + +// TestPersistPaidRenewal 续费叠加:未过期顺延(base=现有到期),已过期从当前时间起算 +func TestPersistPaidRenewal(t *testing.T) { + phone := uniquePhone() + order1 := uniqueOrderId("O-test-renew-1-") + insertOrder(t, &entity.PaymentOrder{ + OrderId: order1, + PhoneNum: phone, + PlanId: "week", + Channel: consts.ChannelAlipay, + AmountCents: 5600, + Status: consts.OrderStatusCreated, + CreatedAt: gtime.Now(), + }) + if err := Payment.persistPaid(ctx(), order1, uniqueOrderId("ali-trade-"), consts.ChannelAlipay, 5600); err != nil { + t.Fatalf("persistPaid 1: %v", err) + } + lic, _ := dao.License.GetByPhone(ctx(), phone) + + // 第二次购买 month:base = 现有到期(未过期),30 天叠加 + order2 := uniqueOrderId("O-test-renew-2-") + insertOrder(t, &entity.PaymentOrder{ + OrderId: order2, + PhoneNum: phone, + PlanId: "month", + Channel: consts.ChannelWechat, + AmountCents: 18000, + Status: consts.OrderStatusCreated, + CreatedAt: gtime.Now(), + }) + if err := Payment.persistPaid(ctx(), order2, uniqueOrderId("wx-trade-"), consts.ChannelWechat, 0); err != nil { + t.Fatalf("persistPaid 2: %v", err) + } + lic2, _ := dao.License.GetByPhone(ctx(), phone) + want := lic.ExpiresAt.Time.AddDate(0, 0, 30) + if !lic2.ExpiresAt.Time.Equal(want) { + t.Fatalf("renewal expiresAt = %s, want %s (base %s)", lic2.ExpiresAt, want, lic.ExpiresAt) + } + // 购买的套餐记录在 payment_order,license 只留到期时间(此处验证第二次下单的订单快照) + o2, _ := dao.PaymentOrder.GetByPk(ctx(), order2) + if o2 == nil || o2.PlanId != "month" { + t.Fatalf("order plan snapshot = %v, want month", o2) + } + + // 支付宝回调金额不匹配 → 拒绝 + order3 := uniqueOrderId("O-test-renew-3-") + insertOrder(t, &entity.PaymentOrder{ + OrderId: order3, + PhoneNum: uniquePhone(), + PlanId: "day", + Channel: consts.ChannelAlipay, + AmountCents: 1000, + Status: consts.OrderStatusCreated, + CreatedAt: gtime.Now(), + }) + if err := Payment.persistPaid(ctx(), order3, uniqueOrderId("ali-trade-"), consts.ChannelAlipay, 999); err == nil { + t.Fatal("amount mismatch should fail") + } +} + +// TestCreateSequence 订单创建串行序列:惰性关闭超时订单 + 2 小时内复用 created 订单 +func TestCreateSequence(t *testing.T) { + phone := uniquePhone() + orderStale := uniqueOrderId("O-test-stale-") + orderRecent := uniqueOrderId("O-test-recent-") + // 3 小时前的 stale 订单 + 1 分钟前的有效订单 + now := gtime.Now() + insertOrder(t, &entity.PaymentOrder{ + OrderId: orderStale, PhoneNum: phone, PlanId: "day", Channel: consts.ChannelWechat, + AmountCents: 1000, Status: consts.OrderStatusCreated, + CreatedAt: gtime.New(now.Time.Add(-3 * time.Hour)), + }) + insertOrder(t, &entity.PaymentOrder{ + OrderId: orderRecent, PhoneNum: phone, PlanId: "day", Channel: consts.ChannelWechat, + AmountCents: 1000, Status: consts.OrderStatusCreated, + CreatedAt: gtime.New(now.Time.Add(-1 * time.Minute)), + }) + + // 复现 Create 的串行事务序列 + var recent *entity.PaymentOrder + err := common.Serial().Submit(ctx(), func() error { + return g.DB().Transaction(ctx(), func(ctx context.Context, tx gdb.TX) error { + window := gtime.New(now.Time.Add(-consts.PendingOrderWindowSeconds * time.Second)) + if err := dao.PaymentOrder.CloseStaleCreatedInTx(ctx, tx, phone, window); err != nil { + return err + } + var err error + recent, err = dao.PaymentOrder.GetRecentCreatedInTx(ctx, tx, phone, window) + return err + }) + }) + if err != nil { + t.Fatal(err) + } + + stale, _ := dao.PaymentOrder.GetByPk(ctx(), orderStale) + if stale.Status != consts.OrderStatusClosed { + t.Fatalf("stale order status = %s, want closed", stale.Status) + } + if recent == nil || recent.OrderId != orderRecent { + t.Fatalf("recent = %+v, want %s", recent, orderRecent) + } +} + +// TestTradeNoNullable 未支付订单 trade_no 必须落 NULL:UNIQUE 约束下多个 created 订单(同渠道不同账号)可共存 +func TestTradeNoNullable(t *testing.T) { + for _, channel := range []string{consts.ChannelAlipay, consts.ChannelAlipay, consts.ChannelWechat, consts.ChannelWechat} { + insertOrder(t, &entity.PaymentOrder{ + OrderId: uniqueOrderId("O-test-trade-"), PhoneNum: uniquePhone(), PlanId: "day", Channel: channel, + AmountCents: 1000, Status: consts.OrderStatusCreated, CreatedAt: gtime.Now(), + }) + } +} + +// TestCalcExpiresAt 自然日语义:day 当天 24 点失效、week/month 自生效日起累计 +func TestCalcExpiresAt(t *testing.T) { + base := time.Date(2026, 8, 22, 15, 30, 0, 0, time.Local) + start := time.Date(2026, 8, 22, 0, 0, 0, 0, time.Local) + cases := []struct { + days int + want time.Time + }{ + {1, start.AddDate(0, 0, 1)}, // 当天 24:00 + {7, start.AddDate(0, 0, 7)}, // 第 7 天 24:00 + {30, start.AddDate(0, 0, 30)}, + } + for _, c := range cases { + got := License.CalcExpiresAt(base, c.days) + if !got.Time.Equal(c.want) { + t.Fatalf("CalcExpiresAt(%d) = %s, want %s", c.days, got, c.want) + } + } +} diff --git a/server/biz/service/testdata/config.yml b/server/biz/service/testdata/config.yml new file mode 100644 index 0000000..03d5c96 --- /dev/null +++ b/server/biz/service/testdata/config.yml @@ -0,0 +1,26 @@ +# 服务层白盒测试专用配置(独立临时库,不提交运行数据) +# 运行方式: cd server && GF_GCFG_FILE=biz/service/testdata/config.yml go test ./biz/service/ +server: + address: ":0" + +database: + default: + type: sqlite + link: "sqlite::@file(testdata/observer_test.db)" + cache: + ttl: 30 + +auth: + secret: "test-auth-secret" + tokenTtl: 2592000 + +plans: + - id: day + days: 1 + price_cents: 1000 + - id: week + days: 7 + price_cents: 5600 + - id: month + days: 30 + price_cents: 18000 diff --git a/server/common/admin_auth.go b/server/common/admin_auth.go new file mode 100644 index 0000000..620bce7 --- /dev/null +++ b/server/common/admin_auth.go @@ -0,0 +1,23 @@ +package common + +import ( + "net/http" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" +) + +// AdminAuth 管理端静态 token 鉴权中间件:请求头 X-Admin-Token 须与 config.yml +// admin.token 一致;token 未配置(空)时管理接口全部拒绝。 +func AdminAuth(r *ghttp.Request) { + want := g.Cfg().MustGet(r.GetCtx(), "admin.token", "").String() + if want == "" || r.Header.Get("X-Admin-Token") != want { + r.Response.WriteStatusExit(http.StatusUnauthorized, g.Map{ + "code": gcode.CodeNotAuthorized.Code(), + "message": "管理端未授权", + "data": nil, + }) + } + r.Middleware.Next() +} diff --git a/server/common/auth.go b/server/common/auth.go new file mode 100644 index 0000000..87c75bb --- /dev/null +++ b/server/common/auth.go @@ -0,0 +1,126 @@ +package common + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" +) + +// 登录 token(HMAC-SHA256 自签名,无状态): +// token = base64url(payload) + "." + hex(HMAC-SHA256(payload, auth.secret)) +// payload = {"phone": "...", "exp": };secret 轮换即全员下线。 + +var ctxKeyPhone = struct{}{} + +// AuthTokenTtl 登录 token 有效期(秒),来自 config.yml auth.tokenTtl,缺失或非法回退 30 天。 +func AuthTokenTtl(ctx context.Context) time.Duration { + ttl := g.Cfg().MustGet(ctx, "auth.tokenTtl", 2592000).Int64() + if ttl <= 0 { + ttl = 2592000 + } + return time.Duration(ttl) * time.Second +} + +// SignToken 签发登录 token(secret 未配置时报错,注册/登录不可用) +func SignToken(ctx context.Context, phone string) (string, error) { + secret := g.Cfg().MustGet(ctx, "auth.secret", "").String() + if secret == "" { + return "", gerror.New("登录未配置(检查 config.yml auth.secret)") + } + payload, err := json.Marshal(map[string]any{ + "phone": phone, + "exp": time.Now().Add(AuthTokenTtl(ctx)).Unix(), + }) + if err != nil { + return "", gerror.Wrap(err, "序列化 token 载荷失败") + } + return tokenOf(payload, secret), nil +} + +// ParseToken 校验并解析 token,返回 phone +func ParseToken(ctx context.Context, token string) (string, error) { + secret := g.Cfg().MustGet(ctx, "auth.secret", "").String() + if secret == "" { + return "", gerror.New("登录未配置") + } + i := strings.LastIndex(token, ".") + if i <= 0 { + return "", gerror.NewCode(gcode.CodeNotAuthorized, "token 格式错误") + } + payload, sig := token[:i], token[i+1:] + raw, err := base64.RawURLEncoding.DecodeString(payload) + if err != nil { + return "", gerror.NewCode(gcode.CodeNotAuthorized, "token 载荷无效") + } + // 签名覆盖原始载荷字节(签发时对 raw 签名,非 base64 串) + if !hmac.Equal([]byte(sig), []byte(signPayload(string(raw), secret))) { + return "", gerror.NewCode(gcode.CodeNotAuthorized, "token 签名无效") + } + var body struct { + Phone string `json:"phone"` + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(raw, &body); err != nil { + return "", gerror.NewCode(gcode.CodeNotAuthorized, "token 载荷无效") + } + if body.Phone == "" || body.Exp < time.Now().Unix() { + return "", gerror.NewCode(gcode.CodeNotAuthorized, "token 已过期") + } + return body.Phone, nil +} + +func tokenOf(payload []byte, secret string) string { + return base64.RawURLEncoding.EncodeToString(payload) + "." + signPayload(string(payload), secret) +} + +func signPayload(payload, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(payload)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// AuthRequired 登录态鉴权中间件:校验 Authorization: Bearer , +// 解出手机号注入请求上下文(service 经 PhoneFromCtx 读取)。 +func AuthRequired(r *ghttp.Request) { + phone, err := ParseToken(r.GetCtx(), bearerToken(r.Header.Get("Authorization"))) + if err != nil { + r.Response.WriteStatusExit(http.StatusUnauthorized, g.Map{ + "code": gcode.CodeNotAuthorized.Code(), + "message": "登录已失效,请重新登录", + "data": nil, + }) + } + r.SetCtx(WithPhone(r.GetCtx(), phone)) + r.Middleware.Next() +} + +func bearerToken(header string) string { + if strings.HasPrefix(header, "Bearer ") { + return strings.TrimPrefix(header, "Bearer ") + } + return "" +} + +// PhoneFromCtx 取 AuthRequired 注入的登录手机号 +func PhoneFromCtx(ctx context.Context) string { + if v, ok := ctx.Value(ctxKeyPhone).(string); ok { + return v + } + return "" +} + +// WithPhone 注入登录手机号到 ctx(AuthRequired 中间件与白盒测试共用) +func WithPhone(ctx context.Context, phone string) context.Context { + return context.WithValue(ctx, ctxKeyPhone, phone) +} diff --git a/server/common/base_dao.go b/server/common/base_dao.go new file mode 100644 index 0000000..4a0b13f --- /dev/null +++ b/server/common/base_dao.go @@ -0,0 +1,89 @@ +package common + +import ( + "context" + "database/sql" + "errors" + "os" + "path/filepath" + "regexp" + "time" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" +) + +// IsNoRows 判断 Scan 空结果(结构体 Scan 无行时返回 sql.ErrNoRows), +// dao 查询方法据此返回 nil 实体而非错误。 +func IsNoRows(err error) bool { + return err != nil && errors.Is(err, sql.ErrNoRows) +} + +// CacheTTL 查询缓存 TTL(秒),来自 config.yml database.cache.ttl,缺失或非法回退默认值。 +func CacheTTL(ctx context.Context) time.Duration { + ttl := g.Cfg().MustGet(ctx, "database.cache.ttl", 30).Int() + if ttl <= 0 { + ttl = 30 + } + return time.Duration(ttl) * time.Second +} + +// CacheOption 构造带显式缓存键的查询缓存选项:键须含业务参数(如 license:设备号), +// 写操作后必须按同键调用 ClearCache,否则「库里已改、查询还是旧值」。 +func CacheOption(ctx context.Context, key string) gdb.CacheOption { + return gdb.CacheOption{Duration: CacheTTL(ctx), Force: false, Name: key} +} + +// gdb 的查询缓存键 = 固定前缀 + 自定义 Name(见 gdb.genSelectCacheKey,前缀常量未导出), +// 清除时必须拼上同一前缀,否则键对不上、缓存永远清不掉。 +const selectCachePrefix = "SelectCache:" + +// ClearCache 清除查询缓存(写操作后必须调用)。清除失败仅记录日志:缓存 TTL 自愈兜底, +// 不阻断业务主链路。 +func ClearCache(ctx context.Context, keys ...string) { + if len(keys) == 0 { + return + } + for _, key := range keys { + if _, err := g.DB().GetCache().Remove(ctx, selectCachePrefix+key); err != nil { + g.Log().Warningf(ctx, "清除查询缓存 %q 失败: %+v", key, err) + } + } +} + +// DropLegacyTableIfHasColumn 存量库迁移:表存在旧版本废弃列(账号体系上线前的 device_id) +// 时 DROP 整表(存量数据作废,用户决策),由 dao init 以新结构重建。 +func DropLegacyTableIfHasColumn(ctx context.Context, table, column string) { + res, err := g.DB().GetAll(ctx, "PRAGMA table_info("+table+")") + if err != nil { + panic("检查表结构失败 " + table + ": " + err.Error()) + } + for _, r := range res { + if gconv.String(r["name"]) == column { + if _, err := g.DB().Exec(ctx, "DROP TABLE "+table); err != nil { + panic("DROP 旧表失败 " + table + ": " + err.Error()) + } + g.Log().Warningf(ctx, "存量表 %s 为旧结构(列 %s),已 DROP 由新结构重建(存量数据作废)", table, column) + return + } + } +} + +var fileLinkRegexp = regexp.MustCompile(`@file\(([^)]+)\)`) + +// init 确保 SQLite 数据文件所在目录存在(驱动打开文件前必须已创建,否则报 unable to open)。 +// 必须在任何 g.DB() 调用(含 dao 包 init 建表)之前执行。 +func init() { + ctx := context.Background() + link := g.Cfg().MustGet(ctx, "database.default.link", "").String() + file := link + if m := fileLinkRegexp.FindStringSubmatch(link); len(m) == 2 { + file = m[1] + } + if dir := filepath.Dir(file); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + panic("创建数据目录失败: " + err.Error()) + } + } +} diff --git a/server/common/bind_controller.go b/server/common/bind_controller.go new file mode 100644 index 0000000..ef32a43 --- /dev/null +++ b/server/common/bind_controller.go @@ -0,0 +1,9 @@ +package common + +import "github.com/gogf/gf/v2/net/ghttp" + +// BindController 反射注册一组 controller 到路由组:接口 path/method 唯一来源为 dto 内嵌的 +// g.Meta(携带 path/method/summary),禁止在 main.go 手动逐条注册。 +func BindController(group *ghttp.RouterGroup, controllers ...interface{}) { + group.Bind(controllers...) +} diff --git a/server/common/errors.go b/server/common/errors.go new file mode 100644 index 0000000..851a8bc --- /dev/null +++ b/server/common/errors.go @@ -0,0 +1,14 @@ +package common + +import "github.com/gogf/gf/v2/errors/gcode" + +// 业务错误码(1000+,框架保留 <1000):统一 HTTP 200 + body code!=0 表示失败, +// 客户端按 code 分支。错误统一用 gerror.NewCode(common.CodeXxx, "...") 构造。 +var ( + CodePlanNotConfigured = gcode.New(1001, "套餐不存在或未配置", nil) + CodePaymentNotConfigured = gcode.New(1002, "支付渠道未配置", nil) + CodeOrderNotFound = gcode.New(1003, "订单不存在", nil) + CodeOrderClosed = gcode.New(1004, "订单已关闭,需重新下单", nil) + CodeCallbackVerifyFailed = gcode.New(1005, "回调验签失败", nil) + CodeCallbackMismatch = gcode.New(1006, "回调商户/金额不匹配", nil) +) diff --git a/server/common/page.go b/server/common/page.go new file mode 100644 index 0000000..20a6238 --- /dev/null +++ b/server/common/page.go @@ -0,0 +1,15 @@ +package common + +// NormalizePage 分页钳制:page ≥1(默认 1)、size 1..100(默认 20) +func NormalizePage(page, size int) (int, int) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 20 + } + if size > 100 { + size = 100 + } + return page, size +} diff --git a/server/common/plans.go b/server/common/plans.go new file mode 100644 index 0000000..a1c030c --- /dev/null +++ b/server/common/plans.go @@ -0,0 +1,37 @@ +package common + +import ( + "context" + + "github.com/gogf/gf/v2/frame/g" +) + +// Plan 套餐定价(config.yml plans 节点,静态配置;展示名由 days 派生"N天") +type Plan struct { + Id string `mapstructure:"id"` + Days int `mapstructure:"days"` + PriceCents int64 `mapstructure:"price_cents"` +} + +// ListPlans 全部套餐(按配置声明顺序) +func ListPlans(ctx context.Context) ([]*Plan, error) { + var plans []*Plan + if err := g.Cfg().MustGet(ctx, "plans").Scan(&plans); err != nil { + return nil, err + } + return plans, nil +} + +// GetPlan 按套餐 ID 查找,不存在返回 (nil, nil),调用方用 CodePlanNotConfigured 包装 +func GetPlan(ctx context.Context, id string) (*Plan, error) { + plans, err := ListPlans(ctx) + if err != nil { + return nil, err + } + for _, p := range plans { + if p.Id == id { + return p, nil + } + } + return nil, nil +} diff --git a/server/common/pool.go b/server/common/pool.go new file mode 100644 index 0000000..e83be05 --- /dev/null +++ b/server/common/pool.go @@ -0,0 +1,52 @@ +package common + +import ( + "context" + "sync" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/grpool" + + "observer-server/biz/consts" +) + +// CallbackPool 支付回调处理并发池:验签/落授权等 IO 任务在池内执行并返回结果, +// 并发度来自 config.yml payment.poolSize,缺失或非法时回退 consts 默认值。 +// 池内任务禁止再提交本池(防 worker 饿死死锁)。 +type CallbackPool struct { + pool *grpool.Pool +} + +var ( + callbackPoolOnce sync.Once + callbackPool *CallbackPool +) + +// CallbackPoolInstance 进程级回调池单例(懒初始化,读取配置)。 +func CallbackPoolInstance() *CallbackPool { + callbackPoolOnce.Do(func() { + ctx := context.Background() + size := g.Cfg().MustGet(ctx, "payment.poolSize", consts.PaymentPoolDefaultSize).Int() + if size <= 0 { + size = consts.PaymentPoolDefaultSize + } + callbackPool = &CallbackPool{pool: grpool.New(size, size)} + }) + return callbackPool +} + +// Submit 提交回调任务并等待执行完成,返回任务的 error。 +func (p *CallbackPool) Submit(ctx context.Context, fn func(ctx context.Context) error) error { + res := make(chan error, 1) + if err := p.pool.Add(ctx, func(ctx context.Context) { + res <- fn(ctx) + }); err != nil { + return err + } + select { + case err := <-res: + return err + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/server/common/response.go b/server/common/response.go new file mode 100644 index 0000000..f44810d --- /dev/null +++ b/server/common/response.go @@ -0,0 +1,30 @@ +package common + +import ( + "github.com/gogf/gf/v2/errors/gcode" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" +) + +// UnifiedResponse 统一响应中间件:挂在客户端路由组上,输出 {"code":0,"message":"ok","data":...}。 +// handler 返回 error 时输出 {code, message, data:null}(code 取 gerror 附带的业务码); +// 已直接写响应体的例外场景(SSE、文件导出等)跳过包装。 +func UnifiedResponse(r *ghttp.Request) { + r.Middleware.Next() + + // handler 已直接写响应体(例外场景),不再包装 + if r.Response.BufferLength() > 0 { + return + } + + if err := r.GetError(); err != nil { + code := gerror.Code(err) + if code == gcode.CodeNil { + code = gcode.CodeInternalError + } + r.Response.WriteJson(g.Map{"code": code.Code(), "message": err.Error(), "data": nil}) + return + } + r.Response.WriteJson(g.Map{"code": 0, "message": "ok", "data": r.GetHandlerResponse()}) +} diff --git a/server/common/serial_writer.go b/server/common/serial_writer.go new file mode 100644 index 0000000..3acd95e --- /dev/null +++ b/server/common/serial_writer.go @@ -0,0 +1,45 @@ +package common + +import "context" + +// serialWriter 单写者执行器:所有 SQLite 写链路(含「查订单→算 expiresAt→更新订单→写 license→ +// 清缓存」等读改写事务)排入同一 goroutine 串行执行,规避无 WAL 时并发写 "database is locked"; +// 任务结果经 buffered channel 回调用方,不额外开池(写路径天然无并行点)。 +type serialWriter struct { + jobs chan func() +} + +var serial = &serialWriter{jobs: make(chan func(), 64)} + +func init() { + go func() { + for f := range serial.jobs { + f() + } + }() +} + +// Serial 返回进程级单写者:SQLite 写操作必须经此串行执行。 +func Serial() *serialWriter { + return serial +} + +// Submit 排队执行写任务并等待结果,返回任务的 error;ctx 取消/超时不再等待。 +// 已排队未执行的任务在 ctx 取消后仍会执行(结果入 buffered channel 无泄漏), +// 调用方以返回的 ctx.Err() 为准不再消费其结果。 +func (w *serialWriter) Submit(ctx context.Context, fn func() error) error { + res := make(chan error, 1) + select { + case w.jobs <- func() { + res <- fn() + }: + case <-ctx.Done(): + return ctx.Err() + } + select { + case err := <-res: + return err + case <-ctx.Done(): + return ctx.Err() + } +} diff --git a/server/common/spa.go b/server/common/spa.go new file mode 100644 index 0000000..d849326 --- /dev/null +++ b/server/common/spa.go @@ -0,0 +1,34 @@ +package common + +import ( + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gogf/gf/v2/net/ghttp" +) + +// SpaFallback 单页应用 history 路由回退中间件:prefix 路径下请求对应的静态文件不存在 +// (即前端路由,如 /admin/orders)时回退 index.html;真实静态文件交给后续静态服务处理。 +// root 为前端构建产物目录,prefix 为托管路径前缀(如 "/admin")。 +func SpaFallback(root, prefix string) ghttp.HandlerFunc { + root = filepath.Clean(root) + index := filepath.Join(root, "index.html") + return func(r *ghttp.Request) { + path := r.URL.Path + if r.Method == http.MethodGet && (path == prefix || strings.HasPrefix(path, prefix+"/")) { + rel := strings.TrimPrefix(path, prefix) + candidate := filepath.Clean(filepath.Join(root, rel)) + if !strings.HasPrefix(candidate, root) { + r.Middleware.Next() + return + } + if _, err := os.Stat(candidate); err != nil { + r.Response.ServeFile(index) + return + } + } + r.Middleware.Next() + } +} diff --git a/server/common/sqlite.go b/server/common/sqlite.go new file mode 100644 index 0000000..03a8788 --- /dev/null +++ b/server/common/sqlite.go @@ -0,0 +1,5 @@ +package common + +// 全局统一注册 SQLite 驱动(main 与服务层白盒测试共用同一注册点, +// 测试二进制不编译 main.go,驱动须在 common 层保证加载) +import _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" diff --git a/server/config.yml b/server/config.yml new file mode 100644 index 0000000..9cce51a --- /dev/null +++ b/server/config.yml @@ -0,0 +1,54 @@ +# 视野后端配置 +server: + address: ":8080" + openapiPath: "/api.json" + +database: + default: + type: sqlite + # SQLite 数据文件(运行时数据,不提交 git) + link: "sqlite::@file(./data/observer.db)" + cache: + # 查询缓存 TTL(秒) + ttl: 30 + +auth: + # 登录 token 签名密钥(HMAC-SHA256,必填):换值即全员下线 + secret: "changeme-auth-secret" + # 登录 token 有效期(秒),默认 30 天 + tokenTtl: 2592000 + +admin: + # 后台管理端静态 token:请求头 X-Admin-Token 必须匹配;为空时管理接口全部拒绝 + token: "Tongli686^*^" + +# 套餐定价(静态配置,改价 = 改本节点后重启服务;金额单位为分,展示名由 days 派生"N天") +plans: + - id: day + days: 1 + price_cents: 1000 + - id: week + days: 7 + price_cents: 5600 + - id: month + days: 30 + price_cents: 18000 + +payment: + # 回调验签/IO 处理并发度(缺失或非法时回退默认值) + poolSize: 16 + wechat: + # 微信支付 APP 支付(APIv3),商户号未配置时接口返回「支付未配置」 + appid: "" # 开放平台 AppID + mchid: "" # 商户号 + apiV3Key: "" # APIv3 密钥 + serialNo: "" # 商户证书序列号 + privateKey: "" # 商户 API 私钥文件路径(PEM) + notifyUrl: "" # 回调地址,如 https://api.example.com/api/v1/payment/wechat/notify + alipay: + # 支付宝 APP 支付,未配置时接口返回「支付未配置」 + appid: "" # 开放平台 AppID + privateKey: "" # 应用私钥文件路径(PEM) + alipayPublicKey: "" # 支付宝公钥文件路径(PEM) + sellerId: "" # 收款方 PID(回调校验 app_id/seller_id 用) + notifyUrl: "" # 回调地址,如 https://api.example.com/api/v1/payment/alipay/notify diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 0000000..4f6577a --- /dev/null +++ b/server/docker-compose.yml @@ -0,0 +1,12 @@ +# 视野后端单机部署:前后端一体单端口,./data 挂载持久化(容器重建不丢授权/订单数据) +services: + observer-server: + build: . + container_name: observer-server + restart: unless-stopped + ports: + - "8080:8080" + volumes: + - ./data:/app/data + environment: + TZ: Asia/Shanghai diff --git a/server/go.mod b/server/go.mod new file mode 100644 index 0000000..585f26d --- /dev/null +++ b/server/go.mod @@ -0,0 +1,51 @@ +module observer-server + +go 1.26.1 + +require ( + github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2 + github.com/gogf/gf/v2 v2.10.2 + github.com/smartwalle/alipay/v3 v3.2.31 + github.com/wechatpay-apiv3/wechatpay-go v0.2.21 +) + +require ( + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/clbanning/mxj/v2 v2.7.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/grokify/html-strip-tags-go v0.1.0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/olekukonko/errors v1.1.0 // indirect + github.com/olekukonko/ll v0.0.9 // indirect + github.com/olekukonko/tablewriter v1.1.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/smartwalle/ncrypto v1.0.4 // indirect + github.com/smartwalle/ngx v1.1.2 // indirect + github.com/smartwalle/nsign v1.0.9 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/otel/sdk v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/server/go.sum b/server/go.sum new file mode 100644 index 0000000..6f400da --- /dev/null +++ b/server/go.sum @@ -0,0 +1,128 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw= +github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw= +github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= +github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2 h1:KLS68SWS2W749x7e+eCCOO3UD2Sbw+bIbLEPR8o1FXw= +github.com/gogf/gf/contrib/drivers/sqlite/v2 v2.10.2/go.mod h1:uLcsu73PfpyhRc0Jq0gGAWQjN1tyGU9iBRrYgt/lu7g= +github.com/gogf/gf/v2 v2.10.2 h1:46IO0Uc8e85/FqdftJFskfDejJLBL0JBnGS5qOftUu8= +github.com/gogf/gf/v2 v2.10.2/go.mod h1:Svl1N+E8G/QshU2DUbh/3J/AJauqCgUnxHurXWR4Qx0= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grokify/html-strip-tags-go v0.1.0 h1:03UrQLjAny8xci+R+qjCce/MYnpNXCtgzltlQbOBae4= +github.com/grokify/html-strip-tags-go v0.1.0/go.mod h1:ZdzgfHEzAfz9X6Xe5eBLVblWIxXfYSQ40S/VKrAOGpc= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= +github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI= +github.com/olekukonko/ll v0.0.9/go.mod h1:En+sEW0JNETl26+K8eZ6/W4UQ7CYSrrgg/EdIYT2H8g= +github.com/olekukonko/tablewriter v1.1.0 h1:N0LHrshF4T39KvI96fn6GT8HEjXRXYNDrDjKFDB7RIY= +github.com/olekukonko/tablewriter v1.1.0/go.mod h1:5c+EBPeSqvXnLLgkm9isDdzR3wjfBkHR9Nhfp3NWrzo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/smartwalle/alipay/v3 v3.2.31 h1:KVwWJZ5JvNxPridHrcpTwgJeMzi1IqZopid0sDJQewU= +github.com/smartwalle/alipay/v3 v3.2.31/go.mod h1:0G9wqvo1719hxo6ZWntujuPNyVQFWikS52LF62rRYDg= +github.com/smartwalle/ncrypto v1.0.4 h1:P2rqQxDepJwgeO5ShoC+wGcK2wNJDmcdBOWAksuIgx8= +github.com/smartwalle/ncrypto v1.0.4/go.mod h1:Dwlp6sfeNaPMnOxMNayMTacvC5JGEVln3CVdiVDgbBk= +github.com/smartwalle/ngx v1.1.2 h1:W+K262lHUvdfJ/2e762dIFwN543kxC/4qLkuCQLRS8o= +github.com/smartwalle/ngx v1.1.2/go.mod h1:mx/nz2Pk5j+RBs7t6u6k22MPiBG/8CtOMpCnALIG8Y0= +github.com/smartwalle/nsign v1.0.9 h1:8poAgG7zBd8HkZy9RQDwasC6XZvJpDGQWSjzL2FZL6E= +github.com/smartwalle/nsign v1.0.9/go.mod h1:eY6I4CJlyNdVMP+t6z1H6Jpd4m5/V+8xi44ufSTxXgc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs= +github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/server/main.go b/server/main.go new file mode 100644 index 0000000..59f70cd --- /dev/null +++ b/server/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "os" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/net/ghttp" + "github.com/gogf/gf/v2/util/gconv" + + "observer-server/biz/controller" + "observer-server/common" +) + +func main() { + ctx := context.Background() + initDatabase(ctx) + + s := g.Server() + // 客户端接口组:统一响应包装 {"code":0,"message":"ok","data":...} + // 账号组公开(注册/登录),业务组(订单/授权)需登录态(Authorization: Bearer token) + s.Group("/api/v1", func(group *ghttp.RouterGroup) { + group.Middleware(common.UnifiedResponse) + common.BindController(group, controller.Auth) + }) + s.Group("/api/v1", func(group *ghttp.RouterGroup) { + group.Middleware(common.UnifiedResponse, common.AuthRequired) + common.BindController(group, controller.Order, controller.License) + }) + // 支付回调组:不做统一包装,按渠道应答格式直接返回(微信 SUCCESS/FAIL JSON、支付宝 success/failure 文本) + s.Group("/api/v1/payment", func(group *ghttp.RouterGroup) { + common.BindController(group, controller.Payment) + }) + // 管理端接口组:统一响应包装 + 静态 token 鉴权 + s.Group("/api/v1/admin", func(group *ghttp.RouterGroup) { + group.Middleware(common.UnifiedResponse, common.AdminAuth) + common.BindController(group, controller.Admin) + }) + // 管理端静态页面托管:构建产物输出到 admin_dist(server_admin/ 构建),SPA history 路由回退 index.html; + // 目录不存在(尚未构建前端)时跳过,不影响 API 启动 + if stat, err := os.Stat("./admin_dist"); err == nil && stat.IsDir() { + s.AddStaticPath("/admin", "./admin_dist") + s.BindMiddlewareDefault(common.SpaFallback("./admin_dist", "/admin")) + } else { + g.Log().Warningf(ctx, "admin_dist 不存在,跳过管理端静态托管(cd server_admin && npm run build)") + } + s.Run() +} + +// initDatabase 数据初始化:存量库以 PRAGMA user_version 版本化迁移; +// v2 = 删 license.plan_id 列(无业务语义);v3 = 删 plan 表(套餐已配置化,清残留表)。 +func initDatabase(ctx context.Context) { + res, err := g.DB().GetAll(ctx, "PRAGMA user_version") + if err != nil { + g.Log().Fatalf(ctx, "读取库版本失败: %+v", err) + } + version := 0 + if len(res) > 0 { + version = gconv.Int(res[0]["user_version"]) + } + if version < 2 { + // v2:删除 license.plan_id(只留到期时间;PRAGMA table_info 检测列存在才 DROP, + // 全新库建表已无此列、直接跳过,不丢账号数据) + cols, err := g.DB().GetAll(ctx, "PRAGMA table_info(license)") + if err != nil { + g.Log().Fatalf(ctx, "读取 license 表结构失败: %+v", err) + } + hasPlanId := false + for _, col := range cols { + if gconv.String(col["name"]) == "plan_id" { + hasPlanId = true + break + } + } + if hasPlanId { + if _, err := g.DB().Exec(ctx, "ALTER TABLE license DROP COLUMN plan_id"); err != nil { + g.Log().Fatalf(ctx, "删除 license.plan_id 列失败: %+v", err) + } + } + if _, err := g.DB().Exec(ctx, "PRAGMA user_version = 2"); err != nil { + g.Log().Fatalf(ctx, "库版本写入失败: %+v", err) + } + g.Log().Infof(ctx, "数据库初始化完成(version=2)") + } + if version < 3 { + // v3:套餐改 config.yml 配置后清理残留 plan 表(订单快照 payment_order.plan_id 不受影响) + if _, err := g.DB().Exec(ctx, "DROP TABLE IF EXISTS plan"); err != nil { + g.Log().Fatalf(ctx, "删除残留 plan 表失败: %+v", err) + } + if _, err := g.DB().Exec(ctx, "PRAGMA user_version = 3"); err != nil { + g.Log().Fatalf(ctx, "库版本写入失败: %+v", err) + } + g.Log().Infof(ctx, "数据库初始化完成(version=3)") + } +} diff --git a/server/技术设计.md b/server/技术设计.md new file mode 100644 index 0000000..a903980 --- /dev/null +++ b/server/技术设计.md @@ -0,0 +1,167 @@ +# 视野后端 技术设计 + +支付与授权后端的实现细节与技术决策。文档驱动:涉及本文档的决策变更须先改文档再写代码。 + +## 技术栈与分层 + +Go + GoFrame(分层规范见 `CLAUDE.md`):`controller → service → dao`,SQLite 单机存储,Docker Compose 单机部署(前后端一体单端口)。 + +- controller:`biz/controller`,DTO `g.Meta` 反射注册路由;支付回调组用 `/api/v1/payment` 前缀(不做统一响应包装,按渠道应答格式直接返回),客户端组用 `/api/v1` 前缀(统一响应包装) +- service:`biz/service`,订单创建、回调落授权、授权查询 +- dao:`biz/dao`,单表 CRUD,无业务逻辑 +- 并发:回调处理为 IO 任务,可走 `common` 池;SQLite 写一律回主 goroutine 串行(无 WAL) + +## 套餐配置与数据表 DDL + +**套餐(静态定价)存 `config.yml` `plans` 节点,不建表**(决策:套餐常年不变,改价 = 改配置重启,避免数据库表 + 管理端改价接口的维护成本;存量 `plan` 表由 v3 迁移 DROP)。展示名按 `days` 派生「N天」,无独立 label 字段。 + +```yaml +plans: + - id: day + days: 1 + price_cents: 1000 + - id: week + days: 7 + price_cents: 5600 + - id: month + days: 30 + price_cents: 18000 +``` + +SQLite,金额整数分(INT64),时间存 ISO8601 文本(`gtime.Time` 序列化)。 + +```sql +CREATE TABLE IF NOT EXISTS payment_order ( + order_id TEXT PRIMARY KEY, -- O + 时间戳 + 随机后缀,业务唯一键 + phone_num TEXT NOT NULL, -- 下单账号(手机号),绑定授权归属 + plan_id TEXT NOT NULL, + channel TEXT NOT NULL, -- wechat | alipay + amount_cents INTEGER NOT NULL, -- 下单时锁定套餐价格(快照,防套餐改价) + status TEXT NOT NULL DEFAULT 'created', -- created | paid | closed + wx_trade_no TEXT UNIQUE, -- 微信交易号,幂等唯一约束 + alipay_trade_no TEXT UNIQUE, -- 支付宝交易号,幂等唯一约束 + created_at TEXT NOT NULL, + paid_at TEXT +); + +CREATE TABLE IF NOT EXISTS license ( + phone_num TEXT PRIMARY KEY, -- 手机号账号,一账号一授权记录,续费更新 + password TEXT NOT NULL, -- bcrypt 加盐哈希,不存明文 + expires_at TEXT, -- 未充值 NULL(NULL/过期即未授权) + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_payment_order_phone ON payment_order(phone_num); +``` + +存量库迁移以 `PRAGMA user_version` 版本化标记,禁止重复执行。迁移记录: +- v1 = 建表(各 dao init)+ 种子套餐(v1 已移除) +- v2 = 删除 `license.plan_id` 列(该字段无业务语义,只留到期时间;`PRAGMA table_info` 检测列存在才 `ALTER TABLE ... DROP COLUMN`,新库与已迁移库跳过) +- v3 = `DROP TABLE IF EXISTS plan`(套餐改配置后清残留表,订单快照 `payment_order.plan_id` 不受影响) + +## 账号体系(注册/登录) + +授权与账号合一:**`license` 表即账号表**(决策:不建独立 user 表)——注册插一行(`password`=bcrypt 哈希,`expires_at` 为 NULL),充值/手动授权更新同一行。未充值账号存在但无额度,授权判断 `expires_at > now`(NULL 视为未授权)。下单购买的套餐记录在 `payment_order.plan_id`(订单快照),`license` 表不存套餐只存到期时间。 + +- `POST /api/v1/auth/register`:`{phone, password}` → 插入 license 行;phone 已存在报错;password 服务端 bcrypt 加盐哈希存储 +- `POST /api/v1/auth/login`:`{phone, password}` → bcrypt 校验 → 签发 token;**登录查库必须绕过缓存**(license 查询有 TTL 缓存,密码变更需即时生效) +- token:HMAC-SHA256 自签名(payload = base64({phone, exp}) + 签名),secret 来自 `config.yml` `auth.secret`,有效期 `auth.token_ttl`(默认 30 天);无状态、服务端不存储,secret 轮换即全员下线 +- 鉴权中间件 `common.AuthRequired`:校验 `Authorization: Bearer ` → 解出 phone 注入请求上下文;失败返回统一 401(code 61) +- 需登录态接口:`GET /plans`、`POST /orders`、`POST /orders/{orderId}/confirm`、`GET /license`(phone 一律从 token 解出,客户端不传) + +## 有效期语义(自然日) + +| 套餐 | 有效期 | +|---|---| +| day | 当天 24:00(服务端时区)失效 | +| week | 自生效日起第 7 天 24:00 失效 | +| month | 自生效日起第 30 天 24:00 失效 | + +- `expiresAt` 一律由**服务端**按本机时区计算并下发,客户端只做「是否过期」判断,不参与计算 +- 续费叠加:新授权 `expiresAt = max(现有 expiresAt, now) 起算套餐天数`,即未过期时顺延,已过期时从当前时间起算;禁止覆盖缩短 +- 单位与展示:服务端只下发 `expiresAt`(ISO8601,含时区),前端自行展示剩余时长 + +## 订单创建流程 + +1. `POST /api/v1/orders` 校验 DTO(`planId` 必填、`channel` in wechat/alipay)+ AuthRequired 解出的 `phoneNum` +2. service:查 `config.yml` plans 锁定 `price_cents`(`common.GetPlan`,不存在报"套餐不存在或未配置")→ 生成 `orderId` → 插 `payment_order(status=created, phone_num)` → 调支付渠道统一下单 +3. 微信(APP 支付):V3 统一下单 `appid` 传客户端 AppID → 响应参数(prepay_id、partnerId、nonceStr、timeStamp、sign)组装返回 +4. 支付宝:`alipay.trade.app.pay` → 返回 `orderStr` +5. 下单失败:事务回滚订单(status=closed 或删除),返回错误 +6. 未支付订单回收:创建新订单前,惰性关闭该 `phone_num` 下超时未支付的 created 订单(超时阈值默认 2 小时、可配置,对齐微信支付有效期,保证用户支付成功后回调必然可落授权);若该账号存在 2 小时内的 created 订单,直接复用返回,禁止重复创建(防重复扣款兜底) + +## 支付回调与幂等 + +微信/支付宝回调是**唯一授权来源**,`confirm` 只是加速刷新;回调未到账时 confirm 不落授权。 + +**并发与事务**:回调落授权是「查订单 → 算 `expiresAt` → 更新订单 → 写 license → 清缓存」的读改写链路,微信/支付宝回调与 confirm 可能并发到达同一订单。整条链路必须由 service 在**事务 + 单写者**下串行执行:SQLite 无 WAL,并发写会 `database is locked`;串行化同时保证 `expiresAt` 只按一次现授权状态计算(叠加语义不被并发重复累加)。事务内写方法以 `XxxInTx` 形式实现,由 service 持有 tx 编排(见 `CLAUDE.md` 事务规范)。 + +**微信(APIv3)**:`POST /api/v1/payment/wechat/notify` +- 验签:`Wechatpay-Timestamp/Nonce/Signature` + 平台证书 → 验签通过后 AES-GCM 解密 +- 商户校验:解密后校验 `appid`/`mchid` 与配置一致(防跨商户重放,对称支付宝的 `app_id`/`seller_id` 校验) +- 落授权:`out_trade_no` → 查 `payment_order` → 更新 `status=paid` + `wx_trade_no` → 计算 `expiresAt` 写 `license` +- 幂等:`wx_trade_no` UNIQUE 约束兜底,重复回调忽略(返回 200 空响应);订单状态已是 paid 时直接成功返回 +- 应答:成功返回 `{"code": "SUCCESS"}`,失败返回 `{"code": "FAIL", "message": ...}`(微信会重试) + +**支付宝**:`POST /api/v1/payment/alipay/notify` +- 验签:RSA2 验签 + 校验 `app_id`/`seller_id` 与配置一致、`total_amount` 与订单 `amount_cents`(元)一致 +- 落授权:同上;`alipay_trade_no` UNIQUE 兜底 +- 应答:验签失败返回 `failure`(支付宝会重试),成功返回 `success` + +**客户端 confirm**:`POST /api/v1/orders/{orderId}/confirm`(Bearer token)幂等 —— 订单已 paid 返回 `{"status": "paid"}`;created 且无回调返回 `{"status": "created"}`。禁止在 confirm 里直接落授权。 + +**confirm 轮询(客户端对接约定)**:回调通常 1~5 秒到账。confirm 返回 created 时,客户端按 3s → 10s → 30s 递增重试(最多 3 次,总计 ~43s),期间展示「支付确认中」并禁用重新下单;重试仍 created 则回到授权查询(`GET /license`),服务端回调到账后自然变为 active。**防重复支付**:客户端在订单闭环(confirm 返回 paid)前不得为同一套餐创建新订单;服务端兜底见「订单创建流程」第 6 条(2 小时内复用 created 订单)。 + +## 授权查询 + +`GET /api/v1/license`(Bearer token) +- AuthRequired 解出 `phoneNum` → 查 `license`:`expires_at > now` → `{active: true, expiresAt}`;否则 `{active: false, expiresAt: null}`(含未充值 NULL) +- 查询走 DAO 缓存(`gdb.CacheOption`,TTL 来自配置),落授权后必须清对应缓存(键 `license:{phoneNum}`),否则「库里已改、查询还是旧值」 + +## 安全与风险 + +| 项 | 方案 | +|---|---| +| 回调伪造 | 微信 APIv3 证书验签+解密;支付宝 RSA2 验签,且校验金额/商户一致,禁止只验签不比对 | +| 账号体系 | 手机号+密码登录,密码 bcrypt 哈希存储;token 自签名(`auth.secret` 校验),手机号从 token 解出,客户端不可伪造;授权绑账号,同账号多设备共享 | +| 下单防刷 | 金额不由客户端传入(服务端按 planId 查 `config.yml` plans 锁定),客户端只能选套餐不能改价 | +| 订单号防猜测 | `orderId` 加随机后缀,长度 ≥ 20,不可枚举 | +| 回调重放 | 交易号唯一约束 + 订单状态机(created→paid 单向),paid 后忽略一切变更 | +| 网络异常 | 回调失败返回 FAIL/failure 触发渠道重试;客户端 confirm 失败可重试(幂等) | + +## 客户端对接要点 + +- API 契约:`flutter_app/docs/PaymentApi.md`(字段与本文档一致,以本文档为准) +- 客户端配置替换项:`flutter_app/lib/config/app_config.dart`(`apiBaseUrl`、`wechatAppId`、`alipayAppId`、URL Scheme / Universal Link) +- 微信开放平台需注册 App 包名 + 签名(Android)与 Universal Link(iOS);支付宝开放平台注册 AppID + 密钥对 +- 账号流程:首次进入 App → 注册/登录页(手机号+密码)→ token 存 `flutter_secure_storage` → 主页展示到期时间;点击「识别」按钮**强制调 `GET /license` 服务端校验**(不走本地缓存),未到期才进摄像头,过期/未授权跳付费墙 +- App 内授权缓存:客户端 `flutter_secure_storage` 缓存 `expiresAt` 仅用于主页展示,识别入口一律服务端为准 + +## 后台管理端 + +代码在仓库根 `server_admin/`(Vue3 + Element Plus + Vite),构建产物输出到 `server/admin_dist/` 由后端 `/admin/` 路径托管(前后端一体单端口,SPA 未命中静态文件的 GET 回退 index.html)。开发期 Vite `server.proxy` 将 `/api` 代理到 `:8080`。 + +**鉴权(静态 token)**:管理接口挂在 `/api/v1/admin` 组,`common.AdminAuth` 中间件校验请求头 `X-Admin-Token` 与 `config.yml` `admin.token` 一致,未配置或失配返回 401 统一错误格式(`{"code":401,"message":"管理端未授权","data":null}`)。前端登录页输入 token 后存浏览器 localStorage,请求拦截器自动携带;token 不内嵌构建产物,失配/失效时前端清 token 回登录页。token 属内网管理凭据,`server_admin/.env*` 已 gitignore(前端不再有 env token)。 + +**接口清单**(统一响应包装,`/api/v1/admin` 前缀): + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | /orders | 订单列表:`phoneNum/status/startAt/endAt` 筛选 + `page/size` 分页(size 上限 100),金额返回整数分 | +| GET | /licenses | 账号列表:`phoneNum` 筛选 + 分页(含未充值账号) | +| POST | /licenses/grant | 手动授权 `{phoneNum, planId}`:与支付回调同语义(自然日叠加、单写者串行事务),提交后清授权缓存 | +| POST | /licenses/revoke | 撤销授权 `{phoneNum}`:清空 `expires_at` **保留账号行**(不删密码),清缓存,客户端下次查询即 inactive | + +**结构决策**:管理端是跨表业务面,controller 聚合在 `biz/controller/admin.go`(避免同一 controller 挂客户端组 + 管理组时 `group.Bind` 重复注册路由),service 按「跨表业务流程归入所属表文件」归入各表文件(订单列表 → `service/order.go`,授权 grant/revoke/列表 → `service/license.go`),dto 聚合在 `biz/model/dto/admin.go`。套餐已配置化(`common/plans.go` 读取 config.yml,仅客户端登录组 `GET /plans` 使用),无独立分层文件,管理端不管理套餐。 + +**手动授权语义**:`grant` 等同免费发卡,与支付回调 `persistPaid` 共用自然日叠加规则——`base = max(现有到期, now)`,`expiresAt = base 所在日 0 点 + plan.days`;已过期账号从当前时间起算。整条链路在 `common.Serial()` 单写者 + 事务内执行(SQLite 无 WAL),提交后清 `license:{phoneNum}` 缓存。`revoke` 清空授权字段、保留账号行(密码不删),同一事务内执行并清缓存。 + +**分页约定**:`page` ≥1(默认 1),`size` 1..100(默认 20),service 内钳制;返回 `{total, list}`,`total` 为同条件总数(COUNT)。 + +## 待办/风险 + +- 微信支付需商户号(APP 支付权限)、APIv3 密钥与平台证书;支付宝需商户应用与密钥 —— 当前均未配置,接口按真实 SDK 契约实现,配置走 `config.yml` 占位 +- iOS App Store 对数字内容强制 IAP,微信/支付宝直连有被拒风险(已决策,记录在案) +- 管理端静态 token 由登录页输入(浏览器 localStorage),属内网管理凭据:`config.yml admin.token` 失配/未配置时管理接口全部拒绝;token 不进入前端构建产物与 git +- 手动授权等同免费发卡,仅限运营/客诉排查场景,前端页面带确认弹窗,后端不限制频率(按需再加审计) diff --git a/server_admin/.gitignore b/server_admin/.gitignore new file mode 100644 index 0000000..ba5f0d6 --- /dev/null +++ b/server_admin/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# 环境变量含管理 token(真实值不入库,构建前本地填写) +.env +.env.* + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/server_admin/.vscode/extensions.json b/server_admin/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/server_admin/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/server_admin/README.md b/server_admin/README.md new file mode 100644 index 0000000..1511959 --- /dev/null +++ b/server_admin/README.md @@ -0,0 +1,5 @@ +# Vue 3 + Vite + +This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` + + diff --git a/server_admin/package-lock.json b/server_admin/package-lock.json new file mode 100644 index 0000000..2546dbb --- /dev/null +++ b/server_admin/package-lock.json @@ -0,0 +1,2097 @@ +{ + "name": "server_admin", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "server_admin", + "version": "0.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "axios": "^1.19.0", + "element-plus": "^2.14.5", + "vue": "^3.5.40", + "vue-router": "^5.2.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.8", + "vite": "^8.2.0" + } + }, + "node_modules/@babel/generator": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", + "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "@types/jsesc": "^2.5.0", + "jsesc": "^3.0.2" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-string-parser": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", + "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", + "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", + "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/types": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", + "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.0", + "@babel/helper-validator-identifier": "^8.0.4" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", + "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue-macros/common": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.4.tgz", + "integrity": "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.2.1.tgz", + "integrity": "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.2.1" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz", + "integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.2.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", + "integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.4.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.4.0.tgz", + "integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.4.0", + "@vueuse/shared": "14.4.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.4.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.4.0.tgz", + "integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.4.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.4.0.tgz", + "integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.9.0.tgz", + "integrity": "sha512-IJdzo2vLiElBxKzwS36VsCue/62d6IdWjnPB2v3nuPKeWGynp6FF/CYoLa5i/3jXH/z97ZDdsXz6abpgM6w07A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@babel/types": "^7.29.0", + "ast-kit": "^2.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", + "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.14.5", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.5.tgz", + "integrity": "sha512-bghYy/S+qg87enHPXELirhEdDqsVAUGcGpbGIeG8dz0kwpIkGz7gYsifulBshXX74iRtHib85XWQj0uSH2A1Yg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.8.0", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8", + "@types/lodash": "^4.17.24", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "14.4.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.20", + "lodash": "^4.18.1", + "lodash-es": "^4.18.1", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.3.9" + }, + "peerDependencies": { + "vue": "^3.3.7" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "devOptional": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/local-pkg": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.2.1.tgz", + "integrity": "sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/nostics": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/nostics/-/nostics-1.2.0.tgz", + "integrity": "sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rolldown": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.146.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz", + "integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.4", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz", + "integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.11.tgz", + "integrity": "sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==", + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.2.0.tgz", + "integrity": "sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^8.0.0", + "@vue-macros/common": "^3.1.3", + "@vue/devtools-api": "^8.1.5", + "ast-walker-scope": "^0.9.0", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.2.1", + "magic-string": "^0.30.21", + "mlly": "^1.8.2", + "muggle-string": "^0.4.1", + "nostics": "^1.1.4", + "pathe": "^2.0.3", + "picomatch": "^4.0.5", + "scule": "^1.3.0", + "tinyglobby": "^0.2.17", + "unplugin": "^3.3.0", + "unplugin-utils": "^0.3.2", + "yaml": "^2.9.0" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.34 || ^4.0.0", + "pinia": "^3.0.4 || ^4.0.2", + "vite": "^7.3.0 || ^8.0.0", + "vue": "^3.5.34 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/server_admin/package.json b/server_admin/package.json new file mode 100644 index 0000000..bb4616b --- /dev/null +++ b/server_admin/package.json @@ -0,0 +1,22 @@ +{ + "name": "server_admin", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "axios": "^1.19.0", + "element-plus": "^2.14.5", + "vue": "^3.5.40", + "vue-router": "^5.2.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.8", + "vite": "^8.2.0" + } +} diff --git a/server_admin/public/favicon.svg b/server_admin/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/server_admin/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/server_admin/public/icons.svg b/server_admin/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/server_admin/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server_admin/src/App.vue b/server_admin/src/App.vue new file mode 100644 index 0000000..da1e637 --- /dev/null +++ b/server_admin/src/App.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/server_admin/src/api/request.js b/server_admin/src/api/request.js new file mode 100644 index 0000000..1e6ebf5 --- /dev/null +++ b/server_admin/src/api/request.js @@ -0,0 +1,45 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' +import router from '../router' + +// 管理端请求封装:统一 /api/v1/admin 前缀;token 由登录页输入存 localStorage,随请求携带 +const request = axios.create({ + baseURL: '/api/v1/admin', + timeout: 10000, +}) + +request.interceptors.request.use((config) => { + // 登录页校验时 localStorage 尚无 token,用请求级 authToken 优先 + const token = config.authToken || localStorage.getItem('adminToken') + if (token) { + config.headers['X-Admin-Token'] = token + } + return config +}) + +request.interceptors.response.use( + (resp) => { + const body = resp.data + if (body && body.code === 0) { + return body.data + } + const msg = (body && body.message) || '请求失败' + ElMessage.error(msg) + return Promise.reject(new Error(msg)) + }, + (err) => { + const body = err.response && err.response.data + const code = body && body.code + // 登录页校验 token 失败:不弹全局错误、不跳转,由页面自行提示 + if (code === 61 && !(err.config && err.config.skipAuthError)) { + localStorage.removeItem('adminToken') + ElMessage.error('登录已失效,请重新输入管理 token') + router.push('/login') + return Promise.reject(err) + } + ElMessage.error((body && body.message) || err.message || '网络错误') + return Promise.reject(err) + }, +) + +export default request diff --git a/server_admin/src/assets/hero.png b/server_admin/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 zcmV+cGycqpP)V|)f$;Qooc7=_G zlYe)HToTQIc!$)^+J1M1y0*T%w!p~7%ux`!eRhO?c80XDxKQ*R^lUUMnA>6NT^?feoZ8xxvP32D&s-9ow zqjcM}eesrC)NeDmsf)*P7wJ|K!&xP%Zy4iI8lF)Tv2!reW)tCzg_1=PmOwd1SQfxa z8;58t!=z~Ba7CYlNWVG>he8aRPY|+-JmozNhn!#9i#77Aa_Edt$ijyCWL#=~I>~2X zZNrQ8I0=D+NWD4pq=7~(i zhfThMNw|G>g^y9pGzxX7ZSApl@tIxFcs{p#MX{Ax&XZT+cR#U+OWc@S)pkIuI}dzu zH?^Q=<(y&Vq-oxSLfc0Zmq81bjZWf}RnssBaD6}2g-XJHLcN_|*IOu>m|x$nbm(?E zyNy!Zp=RroS;?Vg*kmoJYBi!n5{_^@rA!)=t#a^;N$8GL!*DsQb}`yvEuX!G@||An znOfUZAevPrkV_qjl|<~3QRZzG&h@C9Y5z zqpNH4xqbF_InIPh)kX}Vn^5kyed|mOuq+2>M;v~KO37a#yrEn3XDqtOl=rc6_KZ!; zreo)DFVB4|>1Zd(bvMI%8uM;3!)YMYu&cG?(PE!B~y@3yKBMt|R zAf=I16tFwPsl)!jDqvYkLHaAQ+f@W1m6F5aZvwhm4JL z{_l)@b;)mDSzle2gyFP5-r1x-5X{G}ot%VyWP@vEW80!Q=f%RTfpg>B*TA^pyWYUQ z<=xPtz}WcZ!;rFl4m1D&FFHv?K~#9!?A%+fn=lXt;9!Fc#kQ;zk~gZFsH z8e5iu@c_pzX&qb8&Dum*oXwB+fm6l6gFfC|o*wgEiy6tw~&co z9Vd_4)P%wP-KwQW7|lN-znGK#?N+j24U=$982myIBM+vsiKsc*@4-rwJxuAaHKna6 zT3wi!C~a4ZKH03qU}_1bKyx0&$CaK7_%Z+Kl$)fF5^op zZApQF2TvDav!s|krTjw-8US6ep z%!VmX4luub+fseQz_D9ATJQ?iQQwD}TZz{-yo#l12a%+7bT@E(X-hyaVS-5vuXc#^ zx^w;L21;NphGVoj*{s3f4dme0y2LC=G1-7THd`#z?;tuC{^9k(dM{Rf2GOxg7Jzho z7nSZHl7?M9kdalX`)YgoKEfiae5+;$(OGeN1eqxrv!ZCVKyH>xiyNqfe8xzY8*7)H zQls8KMp)F4D>ED;idMOU^^WhVF@q>ZSmeB0y~qC~|DB648hr%Sh|*T(4q|w2l?m2+ zvBVw3@7+Mz?^Yc#+se6KM;a<=(W-I>k)$-qL2V*t}VaW`;?P4)WqI%maIDq8!oUcSYAD`}wWjkSyAVsnF65#2zQ zZ>(K*TlS(E#4y$4Zq+e^_&}d)q20hCe3!LfLYP%nQpLJ~gM6a1hJlz3)aS<9C9me| zAcmJ#>tOwBy{HoP0Sm1&_(E+S@6 zgBIFUoei8zJmdpiq8q5=OY7t@`)JWxn_&GvKVr=Zdb_pEL_j|=?f;WK^U9Q0efd#K z9q7SfJTl4pmA$jsZ5oK8@O9#!I3Cv-kL)<8SalSsp#dcpvJ}Nz#G6FC0%9|7Fi#8; zGDJXtj!&GljT3*HE@0EE>G8Se&d)*nkqe}-?`3vPl&UqK?xG z!3XJ4M-x`EuQjhBbu?ik-)rmIt=DF_N?TVMP)8Gjn)TZ2V%H|zENbeix}kOxd@0}Q z>)HuH6Ean!uS#~4g2Ne2WsMGel|h%j9*W_quQheG^JqmKhc*RYzp0wKlGjBq2VzY_ zgOv8WC1+%W=W)k)Yp_`8kfE=uiiwOZTXi8Uj9YGr$f@yJcJ;#&-Nq~sJ7anE(@;QN z=~br%7%7`isKStX|7!1?L(apl^QvPKlrHV4S+6tNVQ*R1iGdC~WMNE1$a+=rpQmcB z>wxiLIBvOnm;u*;9Y!kJdy(T4lk|8>JAm(&wEsFIF1$_*{>2ZNd$V6DS=SfrGxAv0 zzKe377JI`&o9Ljr+VnS*EwehA{f&{cKZF(6*MG5!p5MvrFA3ll{fmRG*L@6^cb;o^ z3Wm8c?Sc6$`>~VEWw(c$Y?nRO;2Q$=ulpqPtM^=1IZx;@xK0PgO7rKQ^WHVLwtgUT z%|JF{^f(VH)wLKQ%dYiu2RmchBdxL0-M?wxxul_z*{h6ZZ`>-k(vizs((vW8Lt6Z6 zY;Dt?@JWyN`O`f;&d1Mb?e%9oyRK1ql?EE5XB2(W)|D1~Rx35$H6@6)$F?)7V|zEO zI}fu0-0}8W5=6sg$fPnZ~7=tTudl?Ecb@pxbo)vni%gP-?hL|%*?62C;x6?@E`VRnJv z?fTb;k4x;TS7Cu-z%J}uy}e-pwpLQ17Q@4DC+FCdAmNKklG$`I_pyw7E{fYmw~{Fj zi?6KcVy=Wrel)EB_DWO|0CKmI|13!gBV?X`Ozp7x>?6jr`>Qz=^4ea35!$*f}) zS$i+x_k+@P2q1RFUH^ZTTk7=n?cjfR>hTq3l3SY~#w+I8SSutXGyhw;Ws~=zMQ%Vc z>$On~47Ut?P*_!TOQ&PFmLAyJieB2X4_Fd_!WxI-AY`q1Lc-oK?+qcOTzlQ?@~x@OT}*9jTVNfl@3rGvZpWI=eKg>T zZb@6YWz)J=IhP7CF|c?G62vMEG%#U}?#86$0jR4sG~i(jRd#jmn`7b(O#?N;3a;1t zhXLssmUwGhp79luw#(*V8WL0|8+E z6=YZ_O@er~$LrD_PYGc(kJgB=;yw#+Z3X6LDUZ(NcwN=B-hjdiHm!JFar%m{(5bEW z@@_VEtG$5;`EJZ|OkJ@l&G9n((w@uNFwmU%bG|s#TbcJJos!{e+bjCjrCq_}LcN!UFgKtgg7siV*7# z!}1whTRRi*-avJPu->C}Z8EiuK$#886+H_#_!btv+rsiBbv2jAJvJ+O0{#}y(%L3H zfjU-kq_-L@2XrL*ae{{qYJkD{@dw%*bkh2P&YS-0!Xt!PRz7KHV0+~j(t9W8lAVWR zt@B*DgURgEz4>WuN>o?_iKcw$?k{||Pg7{Q2o4|VmJ)mg?{VQJA<}zEr^YAAS zgGm5RT4T3p)U;yz-tfBO^kw8?IoG!IVmc+Z3m#}AOQ?5MRa>)OcU!$N^_+yK6ayn? zK>~WK0!#ysuj^oNLakm)Zvu+J)OSubX^kv!c*xgdIvs;kln!rgG4*uZ;w0mQQO4XD zO9P{GNdv!=cQ(CAL{S(%KtuV^zC&Q{%g)PoXnp^gn^>c*`E>$hLYg2HjnbVGtWLa{7zHdG1jT@B{|Dm16 z7K2(jsfG+m*Zxof)iXxu+!H5Mo-0$pkyV3VV4B@Qms46M zuBxGRV@HxU7Wwx-6CB zaU*HO<_qn$5GH>&@?nRy1{z zkik!sLfWQ)r#75)vVwCBU*r_)Q6mp?!j85{#Xqse)ApRdE$V0%I0*~e(_{)5H)`Mk z#rExC>yjhZxuL@|+#v4#<Axw$+VpV zuT;!2Vww$je$DpAW`$FX_Ab|Ip%$;&T$-lW8jS~B$>G}rd>eQG+$h9lQx4Mx0w={m zx9?T6VU`>sR}XClkAhHEShOUe8awiq zmizhL+}5UKs3}6~It7vBTig9dfQ2Q8coo+Miiaw7n~>4ybv2Ptt0^^=VqX(t*Yya9 zr`FxxFX8(v*H=+uJ#JJWIB2A(==HDYx~^zZ2nu?2`}|Wsa*f3h3ixc+U|FDtAG$Y! z*lc_7se5Oso-Cgqe0){{!8H4g$3<8!R<6JOurD;((({c$1(pwb>(#TT!sge@4>r2@ zVL7>U`0`nsWAYErezk4(Z!gMI2?UTo{J3Ajo(u4)KYIRd>BRcG4BoS3G0EXyEp@tw z%P7__?A^a>Q&AKL@ayDO9D*Qkc!NHnO9l}kpp_6hXbMppYL(X1L?njdFT|-h2<_$; zAtDZ!1Rf%|yb!qbWKd}%0b`LzBeyNy43|QO(&h2mxQLUL)|0%agVOW)6TV!&Ip^Ls z`PG2cygM8)IecQx=Fc+nqYRo4hS^^-nM_&-y8?EJXUczP=DIw(GkTJdpEdh<_STs{ z|A)4n1GKdE=Wu!!nYoZHcUQ4S&R;oDOKX2lrkdF(mK>hz<$Pp>igjOcvoRIjlN=W8 zu8Gx5(roqn8$>gEE5vy{GiGeW8Tq{vnf3hS-V=$tZkQuftUVuU8o6k&dn=Yg3)6MOIH>nlK^-2+C6BZITr~1@So?NvG#TwL)|~=1YXGMTLpS<)ziK_CSOabe z=cB#5)yz|@0i9dSo?*CX)}UP=s6)B+F@~Em(u@Q(I9J9i_V{LmMu8BfXYMh~*oPP+ z!3~xTv|(>|=n6ZOtT~C@V!z!w%18*8T2t6}U2S##rC)mekBql&VsBX;$~ByGE$oA9 z`0Wzq8p?R{4)$l*on;!cLa}Dh^Xe?owiQZt9nH1fxxh$pN9K%CtOw?u3>85L7rr!d zXs)l{TZ{xXP&U8exz?9cv~dNNibOmt*K4I$?RxqIBZ0(?Mg-9FS{*9Bc49Qc1`=sIF-rye`aNT1G@4NwXcnyc@+bw_mTsR>5< zF<2;X0QesG_pw|TonqVBhRtfqI>ty(SIu&VOXd0CrLlfp+;WH7HYjhqnu^oAY!9cB z=B6#R?Rfz9BP`dJ=@v_?70s3HxQPk+{6Y+lM85f2NF^00*^OcM0~?JOZfR9ZPYF+# zYSs}(_BUYV8{n@2a1hD^SV41bwmi2uztR;PeBgF1F-`9>`zoNss-@3LaF2sjl~>OaaVmp7PNp+UT`6@}gR%uzqHDVeEZ14{Yt?n%JeQm+t(1_u zSc}oj^{b;+rlS|ME%+LjzSI&xu0Bblxo$MJ-J$kJ?Qu_XUXh}*@*-x@ny|}wVM%Lg z3tNB`yvr*}N?ClGL;H2cglcvErIccU3(eP7>@~4nOIcI~-`P8tSQnx=jI&{9)!1}l z;gQ%_h>ZlPSV@o@Azq1R$C6ja5!^ZGh;YRhhxs58qJWo9@Bceac&yy(pET1hnn`~7@}2L0&dfPKYs$ih7m2}R!25!(hxqA(!UIw; zK4+~Jowy3=RNC6nE=ncU{LH5?*9@W24lacJlvCZXB$CYtE@>c+~H zkV=(5I&gb{xn2!~f&fs2NQgAL6`p|kyt6kpWk}iVlqIp(H;ig`{_U9yxs1jzu^ETM z7~)Rg8C-NueqTYP&U8l{DY=Y47cR zOR@U%$KQV{mkRF|4)z9Y^t3K`@p>duY&QLUFeh6VoV`a`$U@)(z!-N*5Cj<11$EZW&hJLX83TO{lJYP74rlDZQPkm@t<=U^I)x@|UnHHkdQlh?!ltZwl92rE;;^ zZuIappj4dhld1}kttYYV-j|KF1Kus zWBnzttD^00%LFK(wrwNragFub6xiV8QE2rm<`&fcR4SLFcdtLxVuN!Aal-g6dE4%k zARZ}|xeo;K{0yf7@9aua%2j5o)CPcIOc6uLHFJOcgtB5owlcNAwyAHc0QB0Dts?c@ zUemG~j_E&W7R%+x-IO4FJl8e&*2Blmp1S#RA|)geVrxvP)NHdYuxi~g&Etn?QdNK8ZDKZ?QFLU?zh30G|t9G>a_X4zk}Ygw<^$7K!GIn(Io$>(d4ODJQ2XSd%jpK zm7>ptl$a3GyB}5-%p4>Q*p#VL^B{yQMuFCM^#l#+N!Ne z5_PrJWB=@Iy+t)H`g1lX`{bm($KE5I?0c(JEYm#t{F}j!xtsbob0{xu@0TB_*>G7w0ICn zr#VoBktqHZ~XxhiKD*lcG|b;H*|Ny3P^8ceV`sfBRfrhwZ!T+MFZ!F1Bt{q$8d9i6o?~ zODj^POr}&ivSa^R^YFIq7o0giLBKCycH_aU`F6)O6JX%nPTwh~Q`eq6*0iE#Srj2^ z*_hN3%*b83zfafy60@Cp3{J({RlSaEn&E?mrxRNC9GQ7#+f=s! z0KBf-9Ny_v2VbE%aB|Di)5kNJ^t&C`4D(>t7zYUWUFtbxt+Oq=!@O7BU)}>d*R72o zFF)3jQD_lLe4is&xzyJYC1-c{8TX$RU>&>P$%)ufpez0XSAukmh!xcekg`s$c<>-q zI#zn^JU0zzF}V60)o$_gY}PQH>b2M9&8fRZa#OauglPb zeQ@pMm&=!vNgos4CluQjLMV!pfkmxK+35bi^k&=k>9h02?l+u+m0agG;(h2|Jslc-llvtEwn~*w3bx7qnvZACG<8}AGeaDVvcHbKd2>3G^ zSFPULUn-?Pmo^-_`mLZr??uNH`2=I&yajlrF{DtUxMy#Nu}z=3y7qbUA;5`)hibMR zhXL@@uKyV0-2&A@t@!xyrBnMJl&^o@Gx$&5_q6?D=ji5grd-~=?dlg;ur(_V0wjh! zA=JV^C1m+DDkOsgr<%O9ZQFg!0}pD(#PSz4Dr_EyS5$`)VIAv);4n-SFP~YtC7sH= z7&*MfpH;gd*FHbkmD#)hVxb6xjc9~`t?_{=JS+@ip_cTicXxG<=7m9& zPX+Z8IC*GSAXuGCrZDHgR$r%jyk-fctis2Kx4HvZ|B~8uC@o)m^>Hy-O!&TKA?$&n zkP2Xc54w~!=z2?^NafyL*L0V9cbYrugHBBUj`xVyZmGFR&kvk#>1J*Z~i zNTz}?IAdJ$gkqd2!Gw(%LzE!O5s4C7q4%T~e_P{+z=DNDKrG**p=U`d5yg^vp`;Zn zsU=8gd0a9s4s0FPJePWR9eH5=+O^Kks&kC-iblNqTh2&Pw*^(4384f+D8N|fewZu_ zg2ejQ)ov;ztz;NQl7yj;A`(!H!XQu_$sqY9h_IrH*}_%1{L&_YLDvO?%R5Z-t+ClW z_qERbL?HKUZ!nt+!E9S`uoh^5A|DaIHe*_gf1`E_Vq+}{&T@t$EGhMnRjJ4z2w_W8 zp+qjs7as22^&S3wY1?+}^j-I=RcCE>#|39)g(lU7v_8;?=qK(9D8-*pPdiy)P3lIblG`+?%ea| zYoD3dopYt!tKgFicfNmNi(EWE=E4hC6(r|PYtanqJlmt57YOVrr2^tfrG(eG9C##X zu&1t@%L$RIvpj!wUA z8i>Pqot#_+Cnp6L2XPcZy1ar|9MnY+7eNvK1E)@Tr#2KsXq1*>)uUCozT7L##ok?o zhA6ofP4E|b*9tAfG?uf$#}>TIR&1A!yslP8}i7w-EzW(x#9VEvx18k%Tn=-$VV zkOtUr0b2!w3t>h?#8AZl^Az*(6KCGlD;4j~yx};`#2gN1_gv=%7KVzecIRakN{f*4 zeaI>yH;-o4OGhvGTU)(quWI)-q?V*(sVesSMv|wMUQ3hLEt=lBB$KZ9TyHr>)f7o%) zPYeU<3P)*P10*7vE)nA5#{c=6-E-_>r_u4e3i!I2+UksELwDqwMeBZ9FSP$;^Ajro z_@M#_Ss$?ejoB@!wN|kbGKs(0zLo%0QpQXW#t;oC$B0MZYZ&Ej?8~fNhcCVvPo3vo zFn0WWZaPliF^8_}yzb`*f@yg0uWv6HgNI)xa=pO%Ck(C<=-60l#uD3(wXP~c7!NoX z0&^6=N`zcc90F#qt@=Rn@r!3(*1v(Tl{B!m?Mc7yIA+nEHpY{YWr$=)F7rhR1P}(v zt{YhY#;jsW6G>#xhP*B`OCk|Pf+NN;ju1rxa*HAgoGq*rvqw&xe~;t1JA31$s?GBb z*g7&@cbKo4n<`>)!UlIAgR6q&))B0KYU8r66GbFj?8Guw4E%&}Qi_lT003LtoIZei zwD~=XZmeo+yZ2Pq3KYCF-R&11^p= z@H%s+=G`}wrbJ{()Mh71#2SP3Zy3m>l1n?0N-N1Q;z6?oSxr-G(H5m4EO>~&;}VKi zfY}3w+9z>vp#d)hVuu`)vG_aaH%3b=WKMnSu&c31;<3O;bz2iD=w+o4#oBb36 z5ZCF*Gu?zjZIR0S>_%pHY2$k8D^n7Sz_K8tCDeXM+dO<#LSg%h6`~dnVG1N@T7v&e z%wEd1!k{^zfz_1BTW{!$!B%g)J^2b87!9Y>>100X1SgT7s0z$o>^lAA=Gp_cC1(h=*5Tmf8z&LGJJ>$|K^~s`z9*OWz5MFUr?>Bi?_PGBB)#psD5?>n+q{o_ zz7~ez&;t#h8l$jwGPCC&xq2YetXYQT+0F3j(`xmNGf8dj#an|p#I*pvI*kwW4iuB> z+q3_7xB8y;pLzHG-S%+UHQA zvqp;$kmGJY>lLsN4C~&TcvAS1SErTcwcw0r@wngk zShAUA1M9b#g}^pL-zH7Q#z^&j#r9F8BTVfkR&qF<=e35goTu7c|GN)0mokj4m0%~0 zXJ8j4Hc_l;HJ&uU*Iw`8d_EscJ``s0tk9mkKo^&#TYXm-EoAzTQObxa@^u~g2t#T) zJz|rE!I_?i4dCJC=B8(_pZ{YR>|V?0iCcnU;E@$239^x?SYCfNaMHN;CtHIS_zHN9 zTkQc1v@O35okiFtq5_u+5FkY55ap@pi)O?}x0D1c*qB0KpYR}>Ul+B0Vmr}Z@+%mJ|As}sis_=ROPbov@*2thpE&?!V#Qgu$snYvCZ zrkhmkMU+fSf-s8(L37fPr&M*jRs{{THb!aXQu|P9l_-vJhHvLzMGH zE?1U0H_+PmNABp9`|KzkGfrrZ%XvdGo6*<{d5m9~L7 z_^`M;X6xDo=m6LY6RfvJEvsTK1!u8d2HPx|$S}p;sRy!I zWL55Yxu~_B`OP@~(q6&W3#)~I&+MGL%GWR$#udC151^wsswhqlii;rP9jJpiI7o&Z zAb})=HY7?4HA|re3ns`%$)FuvKCFWjhb~?IE)F6dF2K5}poj-NK6Gf;hw$t3=1txY zoxQxZWrQU6K!%|~!m?~Bnw-6Rr!F3BZ{u5!LqnZTDON}Coj9^@&le)V!NYrVwS~B% zEL+>Sr@}qGwGvu|HrOo|gSt__ezN^&%~{*)a=rf7y1HujUcr`zZB<4#l@T#eN)si} z)lZA<{=tKx8E%c9>A(##6}_p+~EZpKsl5a4pj`E*;_-6`ysiv zffA!7=MT1vCz}-m4~tjVey1b2KSR4OEtLd-(_DdUqYZ74LaDkhH?KFh?%WAOP2WbX zp@zT+Dx|5_f%JQiAGvVw!oh+g3e50u!aPfMxdC=E)XB{F5IcEZhePIM- zph6Y`$Oy?JBL<8Ex(SqEhLeQ@XcrdA>a?rx+_~HLA;l14)WmmpH}_w?Pg#HBZs0eS zwypwAW?M-x+3AU-(GGWSJ=ngxUEcEZ5OsX(Qlt!MQ zn^(`S{GHkAv(8@D`EAfSYig%Cxv?z!{=w^F#y)5_d7FuKZH7qlR-#5B0bt806%D0I zT7VdVP_?q*%Rq8UR;JkD4i^RXowt+E%#V2U>TfDqzZSDZ+dR!a#T3I>-z_$q9@k|m zy5~A*m~&JWP@E7a=pc}4kVHTc4h&R;Li7d@f`|hKMLkbb^uhOakNr3&FLjlm~i5NBM< zFaYI{;cpiHCNRdE0dg*>qIm(_t?#$h=(SCw?h3rJV2*ER8{O4^3#=dO)KwklZkoqU zS8i5c%YL*y*4;FY#D=XmkQnYj%LH)?02~gSJH`Qp1XY64g>%c_K$xseI&|e)7vRoL zAqRba$G@%fSGA7X7hQk%_3NVOYVS+$leU_!&6*5uN)8#5ZBz_6ASCA;azYS-Rt@ki zg2NWz(=;t}SC(~Ibl63$5C8FPmhXqb^)5#jaJ~I{Ex3xZ!+2h8$}}h_g@Be>HZ;72 z6#y#>AY3^skuVKF#0WxFBQ()5d5_nWb?c6c>EeMM|Mh+*&wEpPyxHCq{R-Gdr-`hN zF=1sxl&mBoK+#qRLl9#CEN|Fg8>nbmsTg3a1;#M9enQ$RgWk}kp#-5wh=EF&1tl%mJln2V^8o%Qv(*=zEuO7y z=m*8?xpUn-*@h5Cl_3BK3joiGkyaScK+>|MWdMRWm@RT!Q1piAlv5hL@B6>3&GI8) zP!xBc6}ZNIpJLL%2a8Y!+(<=f%WX>_uWVxlga9!D*oYt$l0cxRDMvqfU;Kq_mLK5k z)dvqYcgLa_Lz?3HyeF)@$%$&6lI?r4I>6W#M*<)vq{?&Oqrx``d`mhpVPr> z#q078F6gw_X<=?KR>8%^t%@wbITvNMu!hKiTSkCTJkw>1!e*Y{%31#_yMf=LW7{RJ zYoC^w$6%3cBtVG5)x#{Hg6IVTh9XEcM{gQwXk!R^y95^f-hZ`d{aVa+xW1EO4wDV4 zB?JgD7*?qkvc|$nIykTvNl2x0j3Q!MXoLL^)~}d7jcYf(H8D~c+?$pKL(px>Z3`eb z04RzS6_AgFT6Pn#iZAg$Sl_j8#;6ShF%&(Fag#E2asU@@LaN;=b=Wf7sgPKhfzhBM zC@eFL8^MrnA*9&Khe*Ab@CC9*uyJGXyi(;y2>lQLJZt;ShtJi?3Yf_t`F+$hY!+Q2Ndsx=U+bjTiAy7djLji>7k%k`$9&--f<*BNA3Hy&ZrHH|4 zG5H&9cB?O#zI1_OOf0Ce%mDfQxdtp3vU%(iY6yji3iISS61XLv#z|!zI_sZqza@B+ zyu9st5-h+`H7QUKx9}3w@oU@EO}&cEzG?fu!!bLO->%zkcg;i9^j`S~=WKMnDi1f= P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/server_admin/src/assets/vite.svg b/server_admin/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/server_admin/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/server_admin/src/main.js b/server_admin/src/main.js new file mode 100644 index 0000000..f0e70fe --- /dev/null +++ b/server_admin/src/main.js @@ -0,0 +1,9 @@ +import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import zhCn from 'element-plus/es/locale/lang/zh-cn' +import 'element-plus/dist/index.css' +import './style.css' +import App from './App.vue' +import router from './router' + +createApp(App).use(ElementPlus, { locale: zhCn }).use(router).mount('#app') diff --git a/server_admin/src/router/index.js b/server_admin/src/router/index.js new file mode 100644 index 0000000..445ea30 --- /dev/null +++ b/server_admin/src/router/index.js @@ -0,0 +1,29 @@ +import { createRouter, createWebHistory } from 'vue-router' +import Login from '../views/Login.vue' +import Orders from '../views/Orders.vue' +import Licenses from '../views/Licenses.vue' + +// 生产部署于 /admin/ 前缀(后端静态托管),history 路由由后端 SPA fallback 兜底 +const router = createRouter({ + history: createWebHistory('/admin/'), + routes: [ + { path: '/login', name: 'login', component: Login, meta: { title: '登录' } }, + { path: '/', redirect: '/orders' }, + { path: '/orders', name: 'orders', component: Orders, meta: { title: '订单管理' } }, + { path: '/licenses', name: 'licenses', component: Licenses, meta: { title: '授权管理' } }, + ], +}) + +// 登录守卫:未输入 token 一律回登录页 +router.beforeEach((to) => { + if (to.path !== '/login' && !localStorage.getItem('adminToken')) { + return '/login' + } + return true +}) + +router.afterEach((to) => { + document.title = (to.meta.title ? to.meta.title + ' - ' : '') + '视野管理端' +}) + +export default router diff --git a/server_admin/src/style.css b/server_admin/src/style.css new file mode 100644 index 0000000..610dd9b --- /dev/null +++ b/server_admin/src/style.css @@ -0,0 +1,17 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', + Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif; + color: #303133; + background: #f0f2f5; +} diff --git a/server_admin/src/views/Licenses.vue b/server_admin/src/views/Licenses.vue new file mode 100644 index 0000000..94b68ed --- /dev/null +++ b/server_admin/src/views/Licenses.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/server_admin/src/views/Login.vue b/server_admin/src/views/Login.vue new file mode 100644 index 0000000..ccbff3b --- /dev/null +++ b/server_admin/src/views/Login.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/server_admin/src/views/Orders.vue b/server_admin/src/views/Orders.vue new file mode 100644 index 0000000..b845427 --- /dev/null +++ b/server_admin/src/views/Orders.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/server_admin/vite.config.js b/server_admin/vite.config.js new file mode 100644 index 0000000..14773ea --- /dev/null +++ b/server_admin/vite.config.js @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// 生产托管于后端 /admin/ 路径(server/main.go AddStaticPath),base 与路由前缀保持一致; +// 构建产物输出到 ../server/admin_dist 由后端 SPA fallback 托管 +export default defineConfig({ + plugins: [vue()], + base: '/admin/', + build: { + outDir: '../server/admin_dist', + emptyOutDir: true, + }, + server: { + proxy: { + '/api': 'http://127.0.0.1:8080', + }, + }, +}) diff --git a/settings.gradle.kts b/settings.gradle.kts deleted file mode 100644 index 90bab35..0000000 --- a/settings.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -pluginManagement { - repositories { - google { - content { - includeGroupByRegex("com\\.android.*") - includeGroupByRegex("com\\.google.*") - includeGroupByRegex("androidx.*") - } - } - mavenCentral() - gradlePluginPortal() - } -} - -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - google() - mavenCentral() - } -} - -rootProject.name = "Observer" -include(":app") diff --git a/training/__pycache__/analyze_rfdetr.cpython-310.pyc b/training/__pycache__/analyze_rfdetr.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cbc2cbb11a961608ae72eae2a7711327855d1338 GIT binary patch literal 12276 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 TB22zUNS> datasets/labels/pheasant_rfdetr/ +- 分析JSON -> datasets/analysis/pheasant_rfdetr/ +- 预览图 -> datasets/previews/pheasant_rfdetr/ +""" +import argparse +import base64 +import glob +import json +import os +import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed + +import cv2 +import numpy as np + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +SRC_IMAGES = os.path.join(BASE_DIR, "datasets/images/pheasant") +OUT_LABELS = os.path.join(BASE_DIR, "datasets/labels/pheasant_rfdetr") +OUT_JSON = os.path.join(BASE_DIR, "datasets/analysis/pheasant_rfdetr") +OUT_PREVIEW = os.path.join(BASE_DIR, "datasets/previews/pheasant_rfdetr") + +ENDPOINT = "http://192.168.3.210:18080/v1/detection" +MODEL = "rfdetr-xlarge" +TIMEOUT = 300 +THRESHOLD = 0.08 +CONF_CONFIRMED = 0.2 # 置信度高于此为明确(class 0 红框),否则为疑似(class 1 蓝框) +# RF-DETR 置信度分布:强目标 0.3-0.9,0.05-0.14 多为噪声尾; +# THRESHOLD=0.08 砍掉纯噪声,CONF_CONFIRMED=0.2 只把强目标标红,宁多勿漏 +INPUT_SIZE = 700 # 整图等比缩放后的最长边,不裁剪 +NMS_IOU = 0.4 +WORKERS = 4 +KEEP_CLASSES = set() # 空 = 保留全部类别(RF-DETR 对小目标类别不稳定,定位一致即可) + + +IMG_MIME = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png"} + + +def detect(buf, mime): + body = json.dumps({"model": MODEL, "image": f"data:{mime};base64,{base64.b64encode(buf.tobytes()).decode()}", + "threshold": THRESHOLD}).encode() + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + req = urllib.request.Request(ENDPOINT, data=body, + headers={"Content-Type": "application/json"}, method="POST") + with opener.open(req, timeout=TIMEOUT) as r: + resp = json.loads(r.read().decode()) + return resp.get("detections", []) + + +def nms(boxes, iou_thr): + """boxes: [(x, y, w, h, conf)], 按置信度降序去重""" + boxes = sorted(boxes, key=lambda b: -b[4]) + keep = [] + for b in boxes: + overlap = False + for k in keep: + ix = min(b[0] + b[2], k[0] + k[2]) - max(b[0], k[0]) + iy = min(b[1] + b[3], k[1] + k[3]) - max(b[1], k[1]) + if ix <= 0 or iy <= 0: + continue + inter = ix * iy + union = b[2] * b[3] + k[2] * k[3] - inter + if inter / union > iou_thr: + overlap = True + break + if not overlap: + keep.append(b) + return keep + + +def find_hiding_spots(img, H, W, n_spots=3): + """图像分析疑似藏身点:植被密度/地形边缘(田埂、林田交界、沟渠)/光线调制。 + + 野鸡习性:地面活动,藏身于茂密低矮遮蔽物,避开开阔裸地; + 强光场景偏好阴影遮蔽,弱光场景偏好相对亮且有遮蔽的区域。 + 返回 [(x, y, w, h)] 像素坐标,框面积约占画面 2%~8%。 + """ + hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) + s, v = hsv[..., 1], hsv[..., 2] + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY).astype(np.float32) / 255.0 + b = img[..., 0].astype(np.float32) / 255.0 + g = img[..., 1].astype(np.float32) / 255.0 + r = img[..., 2].astype(np.float32) / 255.0 + veg = np.clip(g - np.maximum(r, b), 0, 1) + veg[s / 255.0 < 0.12] = 0 + tex = np.abs(gray - cv2.GaussianBlur(gray, (0, 0), 3)) + base = veg + tex + grad = cv2.magnitude(cv2.Sobel(base, cv2.CV_32F, 1, 0, ksize=3), + cv2.Sobel(base, cv2.CV_32F, 0, 1, ksize=3)) + light = v / 255.0 + mean_light = float(gray.mean()) + if mean_light > 0.55: + light_w = 1.0 - np.abs(light - 0.25) + else: + light_w = 1.0 - np.abs(light - 0.6) + cover = (veg * 0.55 + tex * 0.30 + grad * 0.15) * (0.4 + 0.6 * light_w) + cover[light < 0.12] = 0 + cover[light > 0.93] *= 0.15 + cover = cv2.GaussianBlur(cover, (0, 0), 7) + windows = [] + for area_frac in (0.02, 0.04, 0.07): + for aspect in (1.0, 1.6, 2.5): + bw = int(round((area_frac * aspect * H * W) ** 0.5)) + bh = int(round((area_frac * H * W / aspect) ** 0.5)) + bw, bh = min(bw, W), min(bh, H) + windows.append((cv2.boxFilter(cover, -1, (bw, bh)), bw, bh)) + stack = np.stack([w[0] for w in windows]) + best = stack.max(axis=0) + best_idx = stack.argmax(axis=0) + floor = max(float(best.max()) * 0.35, 0.02) + spots = [] + for _ in range(n_spots): + idx = np.unravel_index(int(np.argmax(best)), best.shape) + y, x = idx + if best[y, x] < floor: + break + _, bw, bh = windows[int(best_idx[y, x])] + x0 = max(0, min(x - bw // 2, W - bw)) + y0 = max(0, min(y - bh // 2, H - bh)) + spots.append((x0, y0, bw, bh)) + mx, my = max(bw, 64), max(bh, 64) + best[max(0, y - my):min(H, y + my), max(0, x - mx):min(W, x + mx)] = -1 + return [(int(x), int(y), int(w), int(h)) for x, y, w, h in spots] + + +def suppress_overlap(lines): + """class 1(疑似)框与任一 class 0(确认)框重叠时删除,只保留 class 0""" + cls0 = [l for l in lines if l.startswith("0 ")] + if not cls0: + return lines + boxes0 = [tuple(map(float, l.split())) for l in cls0] + keep1 = [] + for l in lines: + if l.startswith("0 "): + continue + c, cx, cy, bw, bh = map(float, l.split()) + x1, y1, x2, y2 = cx - bw / 2, cy - bh / 2, cx + bw / 2, cy + bh / 2 + overlap = False + for kc, kx, ky, kw, kh in boxes0: + ix = min(x2, kx + kw / 2) - max(x1, kx - kw / 2) + iy = min(y2, ky + kh / 2) - max(y1, ky - kh / 2) + if ix > 0 and iy > 0: + overlap = True + break + if not overlap: + keep1.append(l) + return cls0 + keep1 + + +def process(name): + path = os.path.join(SRC_IMAGES, name) + img = cv2.imread(path) + H, W = img.shape[:2] + ext = os.path.splitext(path)[1].lower() + mime = IMG_MIME.get(ext, "image/jpeg") + jpeg_params = [cv2.IMWRITE_JPEG_QUALITY, 100] if ext in (".jpg", ".jpeg") else [] + # 整图等比缩放至最长边 INPUT_SIZE,不裁剪;检测坐标按比例映射回原图 + scale = min(1.0, INPUT_SIZE / max(H, W)) + if scale < 1.0: + small = cv2.resize(img, (round(W * scale), round(H * scale)), + interpolation=cv2.INTER_AREA) + else: + small = img + ok, buf = cv2.imencode(ext, small, jpeg_params) + boxes = [] + for d in detect(buf, mime): + if not KEEP_CLASSES or d.get("class_name") in KEEP_CLASSES: + boxes.append((d["x"] / scale, d["y"] / scale, + d["width"] / scale, d["height"] / scale, d["confidence"])) + # 野鸡目标 20-100px(近大远小);排除大物体误检(田地纹理)与细条误检 + boxes = [b for b in nms(boxes, NMS_IOU) + if 4 <= b[3] <= H * 0.06 and 4 <= b[2] <= W * 0.1 + and 0.3 <= b[2] / b[3] <= 3.0] + hiding = find_hiding_spots(img, H, W) + return name, W, H, boxes, hiding + + +def reclassify(): + """不重新检测:按当前 CONF_CONFIRMED 从分析 JSON 重出 txt 标签与预览""" + total_boxes = total_empty = 0 + for p in sorted(glob.glob(os.path.join(OUT_JSON, "*.json"))): + j = json.load(open(p, encoding="utf-8")) + W, H = j["size"] + stem = os.path.splitext(os.path.basename(p))[0] + lines = [] + for b in j["candidates"]: + x, y, w, h, conf = b["x"], b["y"], b["width"], b["height"], b["confidence"] + cx, cy, bw, bh = (x + w / 2) / W, (y + h / 2) / H, w / W, h / H + cls = 0 if conf >= CONF_CONFIRMED else 1 + lines.append(f"{cls} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") + for b in j.get("hiding_spots", []): + x, y, w, h = b["x"], b["y"], b["width"], b["height"] + cx, cy, bw, bh = (x + w / 2) / W, (y + h / 2) / H, w / W, h / H + lines.append(f"1 {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") + lines = suppress_overlap(lines) + with open(os.path.join(OUT_LABELS, f"{stem}.txt"), "w", encoding="utf-8") as f: + f.write("\n".join(lines) + ("\n" if lines else "")) + draw_preview(os.path.join(SRC_IMAGES, j["image"]), lines, + os.path.join(OUT_PREVIEW, f"{stem}.jpg")) + total_boxes += len(lines) + if not lines: + total_empty += 1 + print(f"重分类完成: {total_boxes} 框, 空图 {total_empty} 张", flush=True) + + +def main(): + ap = argparse.ArgumentParser(description="RF-DETR 切块放大批量检测野鸡候选框") + ap.add_argument("--names", type=str, default="", help="只处理指定编号,逗号分隔") + ap.add_argument("--reclassify", action="store_true", + help="不重新检测,按当前 CONF_CONFIRMED 从分析JSON重出标签与预览") + args = ap.parse_args() + + if args.reclassify: + reclassify() + return + + for d in (OUT_LABELS, OUT_JSON, OUT_PREVIEW): + os.makedirs(d, exist_ok=True) + names = sorted(p.name for p in os.scandir(SRC_IMAGES) + if p.name.startswith("pheasant_") and p.name.endswith(".jpg")) + if args.names: + want = {f"pheasant_{n}.jpg" for n in args.names.split(",")} + names = [n for n in names if n in want] + + total_boxes = total_empty = 0 + with ThreadPoolExecutor(max_workers=WORKERS) as pool: + futures = [pool.submit(process, n) for n in names] + for fut in as_completed(futures): + name, W, H, boxes, hiding = fut.result() + stem = os.path.splitext(name)[0] + lines = [] + for x, y, w, h, conf in boxes: + cx, cy, bw, bh = (x + w / 2) / W, (y + h / 2) / H, w / W, h / H + cls = 0 if conf >= CONF_CONFIRMED else 1 + lines.append(f"{cls} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") + for x, y, w, h in hiding: + cx, cy, bw, bh = (x + w / 2) / W, (y + h / 2) / H, w / W, h / H + lines.append(f"1 {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") + lines = suppress_overlap(lines) + with open(os.path.join(OUT_JSON, f"{stem}.json"), "w", encoding="utf-8") as f: + json.dump({"image": name, "size": [W, H], + "candidates": [{"x": b[0], "y": b[1], "width": b[2], + "height": b[3], "confidence": b[4]} for b in boxes], + "hiding_spots": [{"x": b[0], "y": b[1], "width": b[2], + "height": b[3]} for b in hiding]}, + f, ensure_ascii=False, indent=1) + with open(os.path.join(OUT_LABELS, f"{stem}.txt"), "w", encoding="utf-8") as f: + f.write("\n".join(lines) + ("\n" if lines else "")) + draw_preview(os.path.join(SRC_IMAGES, name), lines, + os.path.join(OUT_PREVIEW, f"{stem}.jpg")) + total_boxes += len(lines) + if not lines: + total_empty += 1 + print(f"{name}: 候选{len(lines)}", flush=True) + + print(f"完成: {len(names)} 张, 候选框 {total_boxes}, 无候选 {total_empty} 张", flush=True) + + +def draw_preview(img_path, lines, out_path): + img = cv2.imread(img_path) + h, w = img.shape[:2] + for line in lines: + cls, cx, cy, bw, bh = map(float, line.split()) + x1, y1 = int((cx - bw / 2) * w), int((cy - bh / 2) * h) + x2, y2 = int((cx + bw / 2) * w), int((cy + bh / 2) * h) + color = (0, 0, 255) if cls == 0 else (255, 0, 0) + cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) + cv2.imwrite(out_path, img) + + +if __name__ == "__main__": + main() diff --git a/training/dump_graph.py b/training/dump_graph.py new file mode 100644 index 0000000..be49a4e --- /dev/null +++ b/training/dump_graph.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""dump TFLite 模型前 N 个 operator 的结构与常量(perm/reshape shape)""" +import struct +import sys + +TENSOR_TYPE = {0: "FLOAT32", 1: "FLOAT16", 2: "INT32", 3: "UINT8", 4: "INT64", + 5: "STRING", 6: "BOOL", 7: "INT16", 8: "COMPLEX64", 9: "INT8", + 10: "FLOAT64", 11: "COMPLEX128", 12: "UINT64", 13: "RESOURCE", + 14: "VARIANT", 15: "UINT32", 16: "UINT16", 17: "INT4", + 18: "BFLOAT16", 19: "FLOAT8_E4M3FN", 20: "FLOAT8_E4M3FNUZ", + 21: "FLOAT8_E5M2", 22: "FLOAT8_E5M2FNUZ"} +BUILTIN = {0: "ADD", 1: "AVERAGE_POOL_2D", 2: "CONCATENATION", 3: "CONV_2D", + 4: "DEPTHWISE_CONV_2D", 5: "DEPTH_TO_SPACE", 6: "DEQUANTIZE", + 7: "EMBEDDING_LOOKUP", 8: "FLOOR", 9: "FULLY_CONNECTED", + 10: "HASHTABLE_LOOKUP", 11: "L2_NORMALIZATION", 12: "L2_POOL_2D", + 13: "LOCAL_RESPONSE_NORMALIZATION", 14: "LOGISTIC", 15: "LSH_PROJECTION", + 16: "LSTM", 17: "MAX_POOL_2D", 18: "MUL", 19: "RELU", 20: "RELU_N1_TO_1", + 21: "RELU6", 22: "RESHAPE", 23: "RESIZE_BILINEAR", 24: "RNN", + 25: "SOFTMAX", 26: "SPACE_TO_DEPTH", 27: "SVDF", 28: "TANH", + 29: "CONCAT_EMBEDDINGS", 30: "SKIP_GRAM", 31: "CALL", 32: "CUSTOM", + 33: "EMBEDDING_LOOKUP_SPARSE", 34: "PAD", 35: "UNIDIRECTIONAL_SEQUENCE_RNN", + 36: "GATHER", 37: "BATCH_TO_SPACE_ND", 38: "SPACE_TO_BATCH_ND", + 39: "TRANSPOSE", 40: "MEAN", 41: "SUB", 42: "DIV", 43: "SQUEEZE", + 44: "UNIDIRECTIONAL_SEQUENCE_LSTM", 45: "STRIDED_SLICE", + 46: "BIDIRECTIONAL_SEQUENCE_RNN", 47: "EXP", 48: "TOPK_V2", + 49: "SPLIT", 50: "LOG_SOFTMAX", 51: "DELEGATE", 52: "BIDIRECTIONAL_SEQUENCE_LSTM", + 53: "CAST", 54: "PREDICT", 55: "CONCATENATION_RELU", 56: "ARG_MAX", + 57: "MINIMUM", 58: "LESS", 59: "NEG", 60: "PADV2", 61: "GREATER", + 62: "GREATER_EQUAL", 63: "LESS_EQUAL", 64: "SELECT", 65: "SLICE", + 66: "SIN", 67: "TRANSPOSE_CONV", 68: "SPARSE_TO_DENSE", 69: "TILE", + 70: "EXPAND_DIMS", 71: "EQUAL", 72: "NOT_EQUAL", 73: "LOG", + 74: "SUM", 75: "SQRT", 76: "RSQRT", 77: "SHAPE", 78: "POW", 79: "ARG_MIN", + 80: "FAKE_QUANT", 81: "REDUCE_PROD", 82: "REDUCE_MAX", 83: "PACK", + 84: "LOGICAL_OR", 85: "LOGICAL_AND", 86: "LOGICAL_NOT", 87: "UNPACK", + 88: "REDUCE_MIN", 89: "FLOOR_DIV", 90: "REDUCE_ANY", 91: "SQUARE", + 92: "ZEROS_LIKE", 93: "FILL", 94: "FLOOR_MOD", 95: "RANGE", + 96: "RESIZE_NEAREST_NEIGHBOR", 97: "LEAKY_RELU", 98: "SQUARED_DIFFERENCE", + 99: "MIRROR_PAD", 100: "ABS", 101: "SPLIT_V", 102: "UNIQUE", + 103: "CEIL", 104: "REVERSE_V2", 105: "ADD_N", 106: "GATHER_ND", + 107: "COS", 108: "WHERE", 109: "RANK", 110: "ELU", 111: "REVERSE_SEQUENCE", + 112: "MATRIX_DIAG", 113: "QUANTIZE", 114: "MATRIX_SET_DIAG", 115: "ROUND", + 116: "HARD_SWISH", 117: "IF", 118: "WHILE", 119: "NON_MAX_SUPPRESSION_V4", + 120: "NON_MAX_SUPPRESSION_V5", 121: "SCATTER_ND", 122: "SELECT_V2", + 123: "DENSIFY", 124: "SEGMENT_SUM", 125: "BATCH_MATMUL", 126: "PLACEHOLDER_FOR_GREATER_OP_CODES", + 127: "CUMSUM", 128: "CALL_ONCE", 129: "BROADCAST_TO", 130: "RFFT2D", + 131: "CONV_3D", 132: "IMAG", 133: "REAL", 134: "COMPLEX_ABS", 135: "HASHTABLE", + 136: "HASHTABLE_FIND", 137: "HASHTABLE_IMPORT", 138: "HASHTABLE_SIZE", + 139: "REDUCE_ALL", 140: "CONV_3D_TRANSPOSE", 141: "VAR_HANDLE", + 142: "READ_VARIABLE", 143: "ASSIGN_VARIABLE", 144: "BROADCAST_ARGS", + 145: "RANDOM_STANDARD_NORMAL", 146: "BUCKETIZE", 147: "RANDOM_UNIFORM", + 148: "MULTINOMIAL", 149: "GELU", 150: "DYNAMIC_UPDATE_SLICE", + 151: "IRFFT2D", 152: "EXP", 153: "PRELU", 154: "MAXIMUM", 155: "ARG_MAX", + 156: "ARG_MIN", 157: "GELU", 158: "DYNAMIC_UPDATE_SLICE", 159: "RELU_0_TO_1", + 160: "REDUCE_PROD", 161: "RELU_0_TO_1", 162: "REDUCE_PROD"} + + +class FB: + def __init__(self, data): + self.d = data + + def u32(self, p): return struct.unpack_from(" vt + vs: return None + f = self.u16(off) + return None if f == 0 else t + f + + def deref(self, p): return p + self.u32(p) if p is not None else None + + def vec(self, p): + p = self.deref(p) + if p is None: return None + return p + 4, self.u32(p) + + def vtab(self, p): + s, n = self.vec(p) + return [s + i * 4 + self.u32(s + i * 4) for i in range(n)] + + def ivec(self, p): + s, n = self.vec(p) + return [self.i32(s + 4 * i) for i in range(n)] + + +def main(path, n_ops=8): + fb = FB(open(path, "rb").read()) + root = fb.u32(0) + model = root + opcodes = fb.vtab(fb.field(model, 1)) + subgraphs = fb.vtab(fb.field(model, 2)) + sg = subgraphs[0] + tensors = fb.vtab(fb.field(sg, 0)) + ops = fb.vtab(fb.field(sg, 3)) + buffers = fb.vtab(fb.field(model, 4)) + + def tdesc(i): + t = tensors[i] + shape = fb.ivec(fb.field(t, 0)) + tt = fb.field(t, 1) + ttype = TENSOR_TYPE.get(fb.d[tt], fb.d[tt]) if tt else "?" + buf = fb.field(t, 2) + bi = fb.u32(buf) if buf else 0 + name = fb.deref(fb.field(t, 3)) + if name: name = fb.d[name + 4:name + 4 + fb.u32(name)].decode("utf-8", "replace") + return i, name, shape, ttype, bi + + def opcode_of(op): + idx_f = fb.field(op, 0) # opcode_index 是内联标量 + idx = fb.u32(idx_f) if idx_f else 0 + oc = opcodes[idx] + bc_f = fb.field(oc, 1) + bc = fb.i32(bc_f) if bc_f else 0 + return BUILTIN.get(bc, bc) + + for oi, op in enumerate(ops[:n_ops]): + ins = fb.ivec(fb.field(op, 1)) + outs = fb.ivec(fb.field(op, 2)) + print(f"op{oi} {opcode_of(op)}") + for i in ins: + ti, name, shape, ttype, bi = tdesc(i) + extra = "" + if ttype in ("INT32", "UINT8", "INT64", "INT16", "UINT16", "INT8") and bi and bi < len(buffers): + data = buffers[bi] + if data: + start = fb.field(data, 0) + size = fb.u32(start) if start else 0 + if 0 < size <= 64: + extra = f" const={list(fb.d[start + 4:start + 4 + size])}" + print(f" in {name} {shape} {ttype}{extra}") + for i in outs: + ti, name, shape, ttype, bi = tdesc(i) + print(f" out {name} {shape} {ttype}") + + +if __name__ == "__main__": + main(sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 8) diff --git a/training/gen_images_config.json b/training/gen_images_config.json deleted file mode 100644 index 4fae866..0000000 --- a/training/gen_images_config.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "api_format": "dashscope", - "endpoint": "https://ws-ptopj7gg4klw6oo9.cn-beijing.maas.aliyuncs.com", - "model": "qwen-image-3.0", - "api_key": "sk-ws-H.EDRYILD.wiak.MEQCICyn77OTzVRbTbDuC5mmDGqe1fgs3ICRfJfXUKFyWNYdAiBV0Rl7nMfD91ZLco3cWNO8NEg8DImXAAf6f1nkrCA2mQ", - "size": "1152*2048", - "count": 100, - "workers": 10, - "negative_prompt": "野鸡出现在画面下2/3位置的近景中景,野鸡在画面中央,野鸡大特写,野鸡身体暴露70%以上,野鸡大部分身体可见,野鸡露出大半身体少量遮挡,野鸡太大,野鸡占画面高度超过2%,野鸡高度超过26像素,野鸡羽毛纹理清晰,近距离拍摄", - "output_dir": "datasets/images/pheasant", - "timeout": 600, - "retries": 3, - "prompt_template": "生成一张野外观察者(仰角45度、俯视45度、平视)随机视角的照片,模拟等效35mm焦距相机拍摄。画面是开阔的{env},相机位于约100米外、离地约1.6米(站立人眼高度)的观察点,基本平视、略微下俯,地平线位于画面上部约1/3处(约683像素),地面透视平缓,远处田野占据画面主要部分,前景不出现近处地面或作物的特写。按真实物理尺寸换算:环颈雉(野鸡)整体高约0.7米,站在90-110米外,在1152*2048像素的画面中野鸡整体(含尾羽)只有约20~26像素高、身体约12~15像素,是远处很小的目标,绝不可画大、画清晰、特写。野鸡必须位于画面最上方1/3区域内、贴近地平线(野鸡中心y坐标必须小于675像素,即画面顶部676像素范围内),位于画面水平方向两侧区域内,绝不能在画面正中、绝不能在画面下半部。远景中的野鸡细节模糊、轮廓模糊,大部分身体被草丛/植被严密遮挡(遮挡70%以上)不可以是零星遮挡,多数雄性野鸡只露出头部和白色颈部。画面中既有雄性野鸡也有雌性野鸡,最多不超过4只。光线为{light}。本图用于手机端YOLO端侧模型训练:野鸡在图片中尽量保持小目标(高度不超过26像素)、尽量多遮挡(遮挡比例尽量达到70%以上),以模拟手机端真实识别场景。", - "envs": [ - "水田(注满水的稻田,水面平静有倒影,被田埂分割成方块)", - "旱田(干燥的耕地,翻耕过的土垄,地面干裂无积水)", - "林田交界地带(画面一侧是茂密树林,另一侧是开阔农田,中间有草带过渡)", - "裸露土地(荒芜的裸土坡地,碎石和稀疏杂草,无庄稼)", - "雪地(积雪覆盖的田野,白茫茫一片,露出少量枯草杆)", - "玉米地(高大的玉米秆成行排列,叶子枯黄或翠绿,地面有玉米残株)" - ], - "lights": [ - "日出后约30分钟的清晨低角度光线", - "上午的明亮散射光", - "正午的强烈阳光", - "下午的斜射光线", - "日落前约30分钟的黄昏光线" - ] -} diff --git a/training/inspect_tflite.py b/training/inspect_tflite.py new file mode 100644 index 0000000..e699997 --- /dev/null +++ b/training/inspect_tflite.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""纯标准库解析 TFLite flatbuffer,打印 subgraph[0] 输入/输出张量的形状与类型。 + +schema 依据 tensorflow/lite/schema/schema.fbs(字段顺序敏感)。 +""" +import struct +import sys + +TENSOR_TYPE = {0: "FLOAT32", 1: "FLOAT16", 2: "INT32", 3: "UINT8", 4: "INT64", + 5: "STRING", 6: "BOOL", 7: "INT16", 8: "COMPLEX64", 9: "INT8", + 10: "FLOAT64", 11: "COMPLEX128", 12: "UINT64", 13: "RESOURCE", + 14: "VARIANT", 15: "UINT32", 16: "UINT16", 17: "INT4", + 18: "BFLOAT16", 19: "FLOAT8_E4M3FN", 20: "FLOAT8_E4M3FNUZ", + 21: "FLOAT8_E5M2", 22: "FLOAT8_E5M2FNUZ"} + + +class FB: + def __init__(self, data): + self.d = data + + def u32(self, pos): + return struct.unpack_from(" vtable + vsize: + return None + foff = self.u16(off) + return None if foff == 0 else table_pos + foff + + def deref(self, pos): + """uoffset 字段解引用:目标 = uoffset 所在位置 + 其值""" + return pos + self.u32(pos) if pos is not None else None + + def vec(self, pos): + pos = self.deref(pos) + if pos is None: + return None + n = self.u32(pos) + return pos + 4, n + + def vec_table(self, pos): + """返回 [table_pos...],每个元素是相对自身位置的 uoffset""" + start, n = self.vec(pos) + return [start + i * 4 + self.u32(start + i * 4) for i in range(n)] + + def string(self, pos): + pos = self.deref(pos) + if pos is None: + return None + n = self.u32(pos) + return self.d[pos + 4:pos + 4 + n].decode("utf-8", "replace") + + def int_vec(self, pos): + start, n = self.vec(pos) + return [self.i32(start + 4 * i) for i in range(n)] + + +def main(path): + fb = FB(open(path, "rb").read()) + root = fb.u32(0) # Model 表 + subgraphs = fb.vec_table(fb.field(root, 2)) # Model.subgraphs [2] + sg = subgraphs[0] + tensors = fb.vec_table(fb.field(sg, 0)) # SubGraph.tensors [0] + inputs = fb.int_vec(fb.field(sg, 1)) # SubGraph.inputs [1] + outputs = fb.int_vec(fb.field(sg, 2)) # SubGraph.outputs [2] + + def describe(i): + t = tensors[i] + shape = fb.int_vec(fb.field(t, 0)) + ttype = fb.u8(fb.field(t, 1)) if fb.field(t, 1) else 0 + name = fb.string(fb.field(t, 3)) or "?" + return f"{name} shape={shape} type={TENSOR_TYPE.get(ttype, ttype)}" + + print("inputs:") + for i in inputs: + print(" ", describe(i)) + print("outputs:") + for i in outputs: + print(" ", describe(i)) + + +if __name__ == "__main__": + main(sys.argv[1]) diff --git a/training/prepare_yolo.py b/training/prepare_yolo.py new file mode 100644 index 0000000..36ac90b --- /dev/null +++ b/training/prepare_yolo.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""从 RF-DETR 候选标注整理 YOLO 训练集(class 0 确认野鸡 + class 1 疑似/藏身点, 80/20 拆分)""" +import os +import random +import shutil + +BASE = os.path.dirname(os.path.abspath(__file__)) +IMG_SRC = os.path.join(BASE, "datasets/images/pheasant") +LBL_SRC = os.path.join(BASE, "datasets/labels/pheasant_rfdetr") +OUT = os.path.join(BASE, "datasets/yolo") + +random.seed(42) + +stems = sorted(f[:-4] for f in os.listdir(LBL_SRC) if f.endswith(".txt")) +kept, dropped = [], [] +for s in stems: + src_lbl = os.path.join(LBL_SRC, s + ".txt") + with open(src_lbl, encoding="utf-8") as f: + lines = [l for l in f.read().splitlines() if l] + if not lines: + dropped.append(s) + continue + kept.append((s, lines)) + +random.shuffle(kept) +n_val = max(1, round(len(kept) * 0.2)) +val, train = kept[:n_val], kept[n_val:] + +for split, items in (("train", train), ("val", val)): + for d in (os.path.join(OUT, "images", split), os.path.join(OUT, "labels", split)): + os.makedirs(d, exist_ok=True) + for s, lines in items: + shutil.copy(os.path.join(IMG_SRC, s + ".jpg"), os.path.join(OUT, "images", split, s + ".jpg")) + with open(os.path.join(OUT, "labels", split, s + ".txt"), "w", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + +yaml = """path: /opt/pheasant_data/datasets/yolo +train: images/train +val: images/val +names: + 0: pheasant + 1: suspect +""" +with open(os.path.join(OUT, "dataset.yaml"), "w", encoding="utf-8") as f: + f.write(yaml) + +print(f"train: {len(train)} 张, val: {len(val)} 张, 空标注被剔除: {len(dropped)} 张") +print("剔除:", ", ".join(dropped) if dropped else "无") diff --git a/training/train_local.py b/training/train_local.py new file mode 100644 index 0000000..809ae0b --- /dev/null +++ b/training/train_local.py @@ -0,0 +1,21 @@ +from ultralytics import YOLO +import os + +BASE = os.path.dirname(os.path.abspath(__file__)) +YOLO_DIR = os.path.join(BASE, "datasets/yolo") +data = os.path.join(YOLO_DIR, "dataset_local.yaml") +with open(data, "w", encoding="utf-8") as f: + f.write(f"""path: {YOLO_DIR} +train: images/train +val: images/val +names: + 0: pheasant + 1: suspect +""") + +model = YOLO("yolov8n.pt") +model.train(data=data, imgsz=1024, epochs=150, patience=30, batch=8, + device="mps", workers=4, + # project 必须绝对路径,相对路径会被拼到默认 runs/detect 下造成双层嵌套 + project=os.path.join(BASE, "runs/pheasant"), name="yolov8n_1024", + exist_ok=True, plots=True) diff --git a/training/train_server.py b/training/train_server.py new file mode 100644 index 0000000..fcdb29e --- /dev/null +++ b/training/train_server.py @@ -0,0 +1,18 @@ +from ultralytics import YOLO +import os + +# 训练分辨率与端侧推理对齐:tflite 导出/真机推理均为 704x704。 +# 训练 1280 会让模型对"清晰大目标"自信,推理时目标变小置信度崩坏(误报根源)。 +# multi_scale=0.5 随机缩放输入 0.5~1.5x,进一步抗尺度漂移。 +# 训练完成后导出 tflite(imgsz=704,与训练一致)。 +os.chdir("/opt/pheasant_data") +model = YOLO("yolov8n.pt") +model.train(data="datasets/yolo/dataset.yaml", imgsz=704, epochs=150, + patience=30, batch=16, device=0, workers=4, + # project 必须绝对路径,相对路径会被拼到默认 runs/detect 下造成双层嵌套 + project="/opt/pheasant_data/runs/pheasant", name="yolov8n_704", + exist_ok=True, plots=True, multi_scale=0.5) + +# 导出 tflite:输入固定 [1,3,704,704],与 flutter_app/assets/model.tflite 一致 +model.export(format="tflite", imgsz=704) +print("EXPORT_DONE: /opt/pheasant_data/runs/pheasant/yolov8n_704/weights/best.tflite")