From 60d9380a65d45ff34c4eb0407c2c3209a6b8c6c8 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Wed, 9 Sep 2026 11:56:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=B6=88=E8=B4=B9=E6=9C=BA=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20feat:=E4=BA=BA=E5=91=98=E8=B0=83=E7=94=A8=E9=92=B1?= =?UTF-8?q?=E5=8C=85=E9=83=A8=E9=97=A8=E4=B8=8D=E8=B0=83=E5=8A=A8=E7=9A=84?= =?UTF-8?q?bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 +- android/app/src/main/AndroidManifest.xml | 29 +- .../facediscern/database/DatabaseHelper.java | 44 +- .../flutter/FlutterFaceDiscernPlugin.java | 444 +++++- .../flutter/FlutterFaceSubScreenPlugin.java | 44 + .../facediscern/flutter/FlutterTool.java | 627 ++++++-- .../facediscern/flutter/config/Constants.java | 10 +- .../session/FaceEvidenceImageWriter.java | 251 ++++ .../session/FacePreviewRenderer.java | 413 ++++++ .../FaceRecognitionSessionManager.java | 1116 +++++++++++++++ .../tool/face/help/FaceHelper.java | 201 ++- .../face/help/callback/RecognizeCallback.java | 5 +- .../face/model/RecognitionFrameSnapshot.java | 62 + .../face/model/RecognizeConfiguration.java | 99 +- .../model/RecognizeConfigurationStore.java | 142 ++ .../tool/face/model/RecognizeInfo.java | 12 +- .../tool/face/servcie/FaceServer.java | 359 ++++- .../ui/viewmodel/FaceDiscernViewModel.java | 269 +++- .../pj_supermarket_cloud/ApkUpdateManager.kt | 194 +++ .../pj_supermarket_cloud/KioskBootReceiver.kt | 23 + .../KioskDeviceAdminReceiver.kt | 10 + .../pj_supermarket_cloud/KioskModeManager.kt | 355 +++++ .../pj_supermarket_cloud/MainActivity.kt | 338 ++++- .../pj_supermarket_cloud/RootCommandRunner.kt | 191 +++ android/app/src/main/res/values/strings.xml | 5 + .../src/main/res/xml/kiosk_device_admin.xml | 4 + lib/app.dart | 10 +- lib/common/api/app_api.dart | 17 +- lib/common/api/hard_api.dart | 339 +++++ lib/common/api/user_api.dart | 98 ++ lib/common/models/api/hard_api_models.dart | 900 +++++++++++- lib/common/models/api/hard_api_models.g.dart | 626 +++++++- lib/common/models/api/user_api_models.dart | 219 +++ lib/common/models/api/user_api_models.g.dart | 114 ++ .../models/database/database_enum_models.dart | 41 +- .../models/database/database_models.dart | 75 + .../models/database/database_models.g.dart | 64 + lib/common/models/index.dart | 11 + .../models/manage/face_sync_models.dart | 93 ++ .../manage/hardware_input_history_models.dart | 115 ++ .../hardware_input_history_models.g.dart | 57 + .../models/manage/order_evidence_models.dart | 100 ++ .../models/manage/printer_device_models.dart | 77 + .../manage/printer_device_models.g.dart | 15 + .../models/manage/root_access_models.dart | 49 + .../models/manage/scheduled_task_models.dart | 291 ++++ .../manage/scheduled_task_models.g.dart | 103 ++ .../manage/terminal_device_status_models.dart | 183 +++ lib/common/models/mq/mq_models.dart | 50 + lib/common/models/mq/mq_models.g.dart | 24 + .../models/page/app_version_enum_models.dart | 31 + .../page/product_meal_period_models.dart | 163 +++ .../page/reservation_menu_page_models.dart | 596 ++++++++ .../page/reservation_menu_page_models.g.dart | 40 + .../models/page/setting_page_enum_models.dart | 53 +- .../models/plugs/face_discern_models.dart | 671 ++++++++- .../models/plugs/face_discern_models.g.dart | 18 + .../face_recognition_session_models.dart | 281 ++++ .../face_startup_preparation_models.dart | 53 + .../models/plugs/serial_card_models.dart | 195 +++ .../models/plugs/serial_card_models.g.dart | 50 + .../models/plugs/sub_screen_enum_models.dart | 49 + lib/common/routers/names.dart | 3 + lib/common/routers/pages.dart | 10 + .../services/database/dao/face_dao.dart | 60 + .../database/dao/file_upload_record_dao.dart | 40 + .../dao/order_evidence_upload_task_dao.dart | 131 ++ .../database/dao/print_history_dao.dart | 20 + .../database/dao/scheduled_task_dao.dart | 27 + .../database/dao/scheduled_task_log_dao.dart | 70 + lib/common/services/database/database.dart | 10 +- lib/common/services/database/database.g.dart | 895 +++++++++++- .../services/database/database_migration.dart | 190 +++ .../services/database/entity/face_entity.dart | 16 + .../database/entity/face_entity.g.dart | 10 + .../entity/file_upload_record_entity.dart | 97 ++ .../entity/file_upload_record_entity.g.dart | 55 + .../database/entity/order_entity.g.dart | 1 + .../order_evidence_upload_task_entity.dart | 211 +++ .../database/entity/print_history_entity.dart | 25 +- .../entity/print_history_entity.g.dart | 12 + .../entity/scheduled_task_entity.dart | 87 ++ .../entity/scheduled_task_entity.g.dart | 40 + .../entity/scheduled_task_log_entity.dart | 135 ++ .../entity/scheduled_task_log_entity.g.dart | 55 + .../face_recognition_session_service.dart | 473 ++++++ lib/common/services/index.dart | 9 + lib/common/style/index.dart | 3 +- lib/common/style/xfj_design_tokens.dart | 77 + .../utils/app/app_device_status_tool.dart | 407 ++++++ lib/common/utils/app/app_face_tool.dart | 331 ++++- lib/common/utils/app/app_network_tool.dart | 122 +- lib/common/utils/app/app_runtime_tool.dart | 602 ++++++++ lib/common/utils/app/app_setting_tool.dart | 712 ++++++++- lib/common/utils/app/app_sub_screen_tool.dart | 88 ++ .../control/product_hour_type_control.dart | 108 ++ lib/common/utils/index.dart | 11 + lib/common/utils/json/json_tool.dart | 51 + lib/common/utils/logger/logger_tool.dart | 338 ++++- lib/common/utils/manage/base/serial_base.dart | 3 + lib/common/utils/manage/http_manage.dart | 236 ++- lib/common/utils/manage/mq_manager.dart | 3 + lib/common/utils/manage/print_manage.dart | 15 + .../utils/manage/serial_card_manage.dart | 11 + .../utils/manage/serial_steelyard_manage.dart | 12 + .../utils/plugs/face_discern_plugs.dart | 276 ++++ .../utils/sync/file_upload_record_tool.dart | 191 +++ .../sync/order_evidence_upload_tool.dart | 857 +++++++++++ .../sync/scheduled_task_storage_tool.dart | 268 ++++ lib/common/utils/sync/sync_check_tool.dart | 1273 +++++++++++++++-- lib/common/utils/sync/sync_face_tool.dart | 1025 ++++++++++--- lib/common/utils/sync/sync_idle_tool.dart | 117 ++ lib/common/utils/sync/sync_print_tool.dart | 338 +++-- .../utils/sync/terminal_auto_update_tool.dart | 283 ++++ .../utils/sync/terminal_heartbeat_tool.dart | 168 +++ .../utils/sync/terminal_log_upload_tool.dart | 316 ++++ lib/common/values/constants.dart | 16 +- lib/common/widgets/app/app_dialog.dart | 643 +++++++++ lib/common/widgets/app/app_page.dart | 3 +- .../app/app_radio_hour_type_dialog.dart | 392 ++++- lib/common/widgets/app/terminal_toolbar.dart | 294 ++++ lib/common/widgets/index.dart | 2 + lib/main.dart | 9 + lib/pages/setting/controller.dart | 214 ++- .../setting/face_setting_controller.dart | 279 ++++ lib/pages/setting/index.dart | 3 + lib/pages/setting/routes/navigation.dart | 26 + lib/pages/setting/sync_logs/controller.dart | 98 ++ lib/pages/setting/sync_logs/view.dart | 498 +++++++ lib/pages/setting/view.dart | 5 + lib/pages/setting/widgets/index.dart | 1 + .../setting/widgets/setting_components.dart | 515 +++++++ .../widgets/setting_cron_rule_dialog.dart | 311 ++++ lib/pages/setting/widgets/setting_face.dart | 1215 +++++++++++++--- .../setting/widgets/setting_face_plugin.dart | 956 +++++++++++++ .../setting/widgets/setting_product.dart | 437 ++++-- lib/pages/setting/widgets/setting_sync.dart | 597 ++++++-- .../drago_usb_printer/android/build.gradle | 40 + .../android/src/main/AndroidManifest.xml | 3 + .../DragoUsbPrinterPlugin.kt | 202 +++ .../lib/drago_usb_printer.dart | 61 + packages/drago_usb_printer/pubspec.yaml | 19 + pubspec.lock | 591 ++++---- pubspec.yaml | 18 +- 144 files changed, 27832 insertions(+), 1666 deletions(-) create mode 100644 android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceSubScreenPlugin.java create mode 100644 android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceEvidenceImageWriter.java create mode 100644 android/app/src/main/java/io/flutter/plugins/facediscern/session/FacePreviewRenderer.java create mode 100644 android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java create mode 100644 android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognitionFrameSnapshot.java create mode 100644 android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStore.java create mode 100644 android/app/src/main/kotlin/com/example/pj_supermarket_cloud/ApkUpdateManager.kt create mode 100644 android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskBootReceiver.kt create mode 100644 android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskDeviceAdminReceiver.kt create mode 100644 android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskModeManager.kt create mode 100644 android/app/src/main/kotlin/com/example/pj_supermarket_cloud/RootCommandRunner.kt create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/xml/kiosk_device_admin.xml create mode 100644 lib/common/models/manage/face_sync_models.dart create mode 100644 lib/common/models/manage/hardware_input_history_models.dart create mode 100644 lib/common/models/manage/hardware_input_history_models.g.dart create mode 100644 lib/common/models/manage/order_evidence_models.dart create mode 100644 lib/common/models/manage/printer_device_models.dart create mode 100644 lib/common/models/manage/printer_device_models.g.dart create mode 100644 lib/common/models/manage/root_access_models.dart create mode 100644 lib/common/models/manage/scheduled_task_models.dart create mode 100644 lib/common/models/manage/scheduled_task_models.g.dart create mode 100644 lib/common/models/manage/terminal_device_status_models.dart create mode 100644 lib/common/models/page/product_meal_period_models.dart create mode 100644 lib/common/models/page/reservation_menu_page_models.dart create mode 100644 lib/common/models/page/reservation_menu_page_models.g.dart create mode 100644 lib/common/models/plugs/face_recognition_session_models.dart create mode 100644 lib/common/models/plugs/face_startup_preparation_models.dart create mode 100644 lib/common/models/plugs/serial_card_models.g.dart create mode 100644 lib/common/services/database/dao/file_upload_record_dao.dart create mode 100644 lib/common/services/database/dao/order_evidence_upload_task_dao.dart create mode 100644 lib/common/services/database/dao/scheduled_task_dao.dart create mode 100644 lib/common/services/database/dao/scheduled_task_log_dao.dart create mode 100644 lib/common/services/database/entity/file_upload_record_entity.dart create mode 100644 lib/common/services/database/entity/file_upload_record_entity.g.dart create mode 100644 lib/common/services/database/entity/order_evidence_upload_task_entity.dart create mode 100644 lib/common/services/database/entity/scheduled_task_entity.dart create mode 100644 lib/common/services/database/entity/scheduled_task_entity.g.dart create mode 100644 lib/common/services/database/entity/scheduled_task_log_entity.dart create mode 100644 lib/common/services/database/entity/scheduled_task_log_entity.g.dart create mode 100644 lib/common/services/face_recognition_session_service.dart create mode 100644 lib/common/style/xfj_design_tokens.dart create mode 100644 lib/common/utils/app/app_device_status_tool.dart create mode 100644 lib/common/utils/app/app_runtime_tool.dart create mode 100644 lib/common/utils/app/app_sub_screen_tool.dart create mode 100644 lib/common/utils/app/control/product_hour_type_control.dart create mode 100644 lib/common/utils/sync/file_upload_record_tool.dart create mode 100644 lib/common/utils/sync/order_evidence_upload_tool.dart create mode 100644 lib/common/utils/sync/scheduled_task_storage_tool.dart create mode 100644 lib/common/utils/sync/sync_idle_tool.dart create mode 100644 lib/common/utils/sync/terminal_auto_update_tool.dart create mode 100644 lib/common/utils/sync/terminal_heartbeat_tool.dart create mode 100644 lib/common/utils/sync/terminal_log_upload_tool.dart create mode 100644 lib/common/widgets/app/app_dialog.dart create mode 100644 lib/common/widgets/app/terminal_toolbar.dart create mode 100644 lib/pages/setting/face_setting_controller.dart create mode 100644 lib/pages/setting/routes/navigation.dart create mode 100644 lib/pages/setting/sync_logs/controller.dart create mode 100644 lib/pages/setting/sync_logs/view.dart create mode 100644 lib/pages/setting/widgets/setting_components.dart create mode 100644 lib/pages/setting/widgets/setting_cron_rule_dialog.dart create mode 100644 lib/pages/setting/widgets/setting_face_plugin.dart create mode 100644 packages/drago_usb_printer/android/build.gradle create mode 100644 packages/drago_usb_printer/android/src/main/AndroidManifest.xml create mode 100644 packages/drago_usb_printer/android/src/main/kotlin/app/sks/client/drago_usb_printer/DragoUsbPrinterPlugin.kt create mode 100644 packages/drago_usb_printer/lib/drago_usb_printer.dart create mode 100644 packages/drago_usb_printer/pubspec.yaml diff --git a/.gitignore b/.gitignore index b6323af..f32effb 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,10 @@ app.*.map.json /android/app/release # FVM Version Cache -.fvm/ \ No newline at end of file +.fvm/ + +# AI agent generated directories +.v2c/ +.video_agent/ +.mimosa/ +.zcode/ \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 036605b..bcff0eb 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + ` @@ -37,7 +39,8 @@ android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:exported="true" android:launchMode="singleTop" - android:theme="@style/LaunchTheme"> + android:theme="@style/LaunchTheme" + android:windowSoftInputMode="adjustResize"> @@ -57,6 +60,30 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java index b4b9e66..b485b57 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/database/DatabaseHelper.java @@ -15,7 +15,8 @@ public class DatabaseHelper extends SQLiteOpenHelper { private static final String DATABASE_PATH = "pj_cashier/database"; private static final String DATABASE_NAME = "cash_register.db"; - private static final int DATABASE_VERSION = 9; + /** 与 Flutter Floor 的 @Database 版本保持一致,避免原生层提前推进 user_version。 */ + private static final int DATABASE_VERSION = 13; public DatabaseHelper(Context context) { super(context, getCustomDatabasePath(context), null, DATABASE_VERSION); @@ -40,6 +41,45 @@ public class DatabaseHelper extends SQLiteOpenHelper { @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { - onCreate(db); + if (oldVersion < 12) { + migrateFaceQuality(db); + } + } + + /** + * 从版本11无损补齐本地人脸质量字段;重复执行安全,避免原生打开数据库时覆盖 Floor 迁移。 + */ + private void migrateFaceQuality(SQLiteDatabase db) { + if (!hasColumn(db, "face", "qualityScore")) { + db.execSQL("ALTER TABLE face ADD COLUMN qualityScore REAL"); + } + if (!hasColumn(db, "face", "qualityStatus")) { + db.execSQL("ALTER TABLE face ADD COLUMN qualityStatus TEXT NOT NULL DEFAULT 'pending'"); + } + if (!hasColumn(db, "face", "qualityCheckedAt")) { + db.execSQL("ALTER TABLE face ADD COLUMN qualityCheckedAt TEXT"); + } + if (!hasColumn(db, "face", "qualityErrorCode")) { + db.execSQL("ALTER TABLE face ADD COLUMN qualityErrorCode INTEGER"); + } + db.execSQL("UPDATE face SET qualityStatus = 'pending' WHERE qualityStatus IS NULL OR qualityStatus NOT IN ('pending', 'detected', 'failed')"); + // Floor 的可空时间转换器使用空字符串承载业务空值,原生兜底升级必须保持同一存储语义。 + db.execSQL("UPDATE face SET qualityCheckedAt = '' WHERE qualityCheckedAt IS NULL"); + db.execSQL("CREATE INDEX IF NOT EXISTS index_face_qualityStatus_qualityScore ON face(qualityStatus, qualityScore)"); + } + + /** + * 检查表中是否已经存在目标字段。 + */ + private boolean hasColumn(SQLiteDatabase db, String table, String column) { + try (Cursor cursor = db.rawQuery("PRAGMA table_info(" + table + ")", null)) { + int nameIndex = cursor.getColumnIndex("name"); + while (cursor.moveToNext()) { + if (nameIndex >= 0 && column.equals(cursor.getString(nameIndex))) { + return true; + } + } + } + return false; } } diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java index fe384db..6fcbaf3 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceDiscernPlugin.java @@ -5,29 +5,45 @@ import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.os.Build; +import android.os.Handler; +import android.os.Looper; import androidx.annotation.NonNull; -import com.google.gson.Gson; - import java.util.List; +import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import io.flutter.Log; import io.flutter.embedding.engine.plugins.FlutterPlugin; import io.flutter.plugin.common.EventChannel; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugins.facediscern.session.FaceRecognitionSessionManager; import io.flutter.plugins.facediscern.tool.face.constants.FaceBrodConstant; +import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfiguration; +import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfigurationStore; import io.flutter.plugins.facediscern.ui.FaceDiscern; +import io.flutter.view.TextureRegistry; -public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.MethodCallHandler, EventChannel.StreamHandler { +public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.MethodCallHandler, + EventChannel.StreamHandler, FaceRecognitionSessionManager.Listener { private static final String TAG = "人脸识别插件"; private static final String CHANNEL_TAG = "local/face_discern"; private static final String EVENT_TAG = "local/face_discern_event"; + private static final String SESSION_EVENT_TAG = "local/face_discern_session_event"; + private static final String REGISTRATION_EVENT_TAG = "local/face_discern_registration_event"; + /** 在库照片质量检测进度事件通道。 */ + private static final String QUALITY_AUDIT_EVENT_TAG = "local/face_discern_quality_audit_event"; + private static final ExecutorService FACE_EXECUTOR = Executors.newSingleThreadExecutor(); + private static final Handler MAIN_HANDLER = new Handler(Looper.getMainLooper()); public static MethodChannel staticChannel; @@ -35,12 +51,24 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me private MethodChannel channel; private EventChannel eventChannel; + private EventChannel sessionEventChannel; + private EventChannel registrationEventChannel; + /** 当前Flutter Engine对应的质量检测进度通道。 */ + private EventChannel qualityAuditEventChannel; + private volatile EventChannel.EventSink faceEventSink; + private volatile EventChannel.EventSink sessionEventSink; + private volatile EventChannel.EventSink registrationEventSink; + /** 当前质量检测进度订阅;页面离开时置空但不终止原生任务。 */ + private volatile EventChannel.EventSink qualityAuditEventSink; private MethodChannel.Result pendingResult; + private TextureRegistry textureRegistry; + private FaceRecognitionSessionManager sessionManager; private FlutterFaceReceiver flutterFaceReceiver; - - private boolean isReceiverRegistered = false; + private boolean receiverRegistered; + private String engineEndpoint = "unknown"; + private final Map previewOutputs = new HashMap<>(); private BroadcastReceiver actionsReceiver = new BroadcastReceiver() { @Override @@ -54,27 +82,94 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me @Override public void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { context = binding.getApplicationContext(); + textureRegistry = binding.getTextureRegistry(); + sessionManager = FaceRecognitionSessionManager.getInstance(context); + sessionManager.addListener(this); channel = new MethodChannel(binding.getBinaryMessenger(), CHANNEL_TAG); - staticChannel = channel; + if (staticChannel == null) { + staticChannel = channel; + engineEndpoint = "main"; + } else { + engineEndpoint = "sub"; + } channel.setMethodCallHandler(this); eventChannel = new EventChannel(binding.getBinaryMessenger(), EVENT_TAG); eventChannel.setStreamHandler(this); + sessionEventChannel = new EventChannel(binding.getBinaryMessenger(), SESSION_EVENT_TAG); + sessionEventChannel.setStreamHandler(new EventChannel.StreamHandler() { + @Override + public void onListen(Object arguments, EventChannel.EventSink events) { + sessionEventSink = events; + events.success(sessionManager.getState()); + } + + @Override + public void onCancel(Object arguments) { + sessionEventSink = null; + } + }); + registrationEventChannel = new EventChannel(binding.getBinaryMessenger(), REGISTRATION_EVENT_TAG); + registrationEventChannel.setStreamHandler(new EventChannel.StreamHandler() { + @Override + public void onListen(Object arguments, EventChannel.EventSink events) { + registrationEventSink = events; + events.success(FlutterTool.getLatestRegistrationProgress()); + } + + @Override + public void onCancel(Object arguments) { + registrationEventSink = null; + } + }); + qualityAuditEventChannel = new EventChannel(binding.getBinaryMessenger(), QUALITY_AUDIT_EVENT_TAG); + qualityAuditEventChannel.setStreamHandler(new EventChannel.StreamHandler() { + /** 订阅后立即返回最新进度,支持页面重建。 */ + @Override + public void onListen(Object arguments, EventChannel.EventSink events) { + qualityAuditEventSink = events; + events.success(FlutterTool.getLatestQualityAuditProgress()); + } + + /** 取消页面订阅,不取消仍在后台执行的批量检测。 */ + @Override + public void onCancel(Object arguments) { + qualityAuditEventSink = null; + } + }); } @Override public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + boolean mainEndpoint = staticChannel == channel; channel.setMethodCallHandler(null); + if (mainEndpoint) staticChannel = null; eventChannel.setStreamHandler(null); - if (actionsReceiver != null && isReceiverRegistered) { - context.unregisterReceiver(actionsReceiver); - } + sessionEventChannel.setStreamHandler(null); + registrationEventChannel.setStreamHandler(null); + if (qualityAuditEventChannel != null) qualityAuditEventChannel.setStreamHandler(null); + faceEventSink = null; + sessionEventSink = null; + registrationEventSink = null; + qualityAuditEventSink = null; + unregisterLegacyReceiver(); + if (sessionManager != null) { + for (Long outputId : previewOutputs.values().toArray(new Long[0])) { + sessionManager.releasePreviewTexture(outputId); + } + } + previewOutputs.clear(); + if (sessionManager != null) sessionManager.removeListener(this); + if (mainEndpoint && sessionManager != null) { + sessionManager.suspendRecognition("主屏 Flutter Engine 已销毁,人脸识别已结束"); + } + textureRegistry = null; } // flutter 方法的回调 @Override public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { String method = call.method; - Log.i(TAG, "执行方法:" + method + ";参数:" + new Gson().toJson(call.arguments) + ";"); + Log.i(TAG, "执行人脸插件方法:" + method); switch (method) { case "jumpFaceDiscern": try { @@ -88,15 +183,125 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me Log.i(TAG, "jumpFaceDiscern error:" + e.getMessage(), e); } break; + case "createPreviewTexture": + try { + Map texture = sessionManager.createPreviewTexture(textureRegistry); + Number createdTextureId = (Number) texture.get("textureId"); + Number previewOutputId = (Number) texture.get("previewOutputId"); + if (createdTextureId == null || previewOutputId == null) { + throw new IllegalStateException("原生未返回有效的人脸预览输出编号"); + } + previewOutputs.put(createdTextureId.longValue(), previewOutputId.longValue()); + Log.i(TAG, "创建人脸预览 endpoint=" + engineEndpoint + ";textureId=" + createdTextureId + ";outputId=" + previewOutputId); + result.success(texture); + } catch (Exception e) { + result.error("PREVIEW_TEXTURE_FAILED", "创建人脸预览纹理失败", e.getMessage()); + } + break; + case "releasePreviewTexture": + Number textureId = call.argument("textureId"); + if (textureId != null) { + Long outputId = previewOutputs.remove(textureId.longValue()); + if (outputId != null) { + Log.i(TAG, "释放人脸预览 endpoint=" + engineEndpoint + ";textureId=" + textureId + ";outputId=" + outputId); + sessionManager.releasePreviewTexture(outputId); + } + } + result.success(true); + break; + case "startRecognition": + try { + if (FlutterTool.isRegistrationInProgress()) { + result.error("REGISTRATION_IN_PROGRESS", "人脸库更新中,请稍后重试", null); + break; + } + result.success(sessionManager.startRecognition(stringArgument(call, "sessionId"), stringArgument(call, "scene"), this)); + } catch (FaceRecognitionSessionManager.SessionBusyException e) { + result.error("SESSION_BUSY", e.getMessage(), e.getActiveSessionId()); + } catch (SecurityException e) { + result.error("CAMERA_PERMISSION_DENIED", e.getMessage(), null); + } catch (Exception e) { + result.error("RECOGNITION_START_FAILED", "启动人脸识别失败", e.getMessage()); + } + break; + case "retryRecognition": + try { + if (FlutterTool.isRegistrationInProgress()) { + result.error("REGISTRATION_IN_PROGRESS", "人脸库更新中,请稍后重试", null); + break; + } + result.success(sessionManager.retryRecognition(stringArgument(call, "sessionId"))); + } catch (Exception e) { + result.error("RECOGNITION_RETRY_FAILED", e.getMessage(), null); + } + break; + case "cancelRecognition": + result.success(sessionManager.cancelRecognition(stringArgument(call, "sessionId"), stringArgument(call, "message"))); + break; + case "finishRecognition": + result.success(sessionManager.finishRecognition(stringArgument(call, "sessionId"))); + break; + case "suspendRecognition": + result.success(sessionManager.suspendRecognition(stringArgument(call, "reason"))); + break; + case "getRecognitionState": + result.success(sessionManager.getState()); + break; + case "hasCamera": + result.success(sessionManager.hasCamera()); + break; + case "cleanupOrderEvidenceFiles": + List rawReferencedPaths = call.argument("referencedPaths"); + Number olderThan = call.argument("olderThan"); + Set referencedPaths = new HashSet<>(); + if (rawReferencedPaths != null) { + for (Object path : rawReferencedPaths) { + if (path instanceof String && !((String) path).trim().isEmpty()) { + referencedPaths.add((String) path); + } + } + } + result.success(sessionManager.cleanupOrderEvidenceFiles( + referencedPaths, olderThan == null ? 0L : olderThan.longValue())); + break; + case "getRecognizeConfiguration": + result.success(RecognizeConfigurationStore.toMap(RecognizeConfigurationStore.load(context))); + break; + case "setRecognizeConfiguration": + try { + RecognizeConfiguration configuration = RecognizeConfigurationStore.save(context, call.arguments instanceof Map ? (Map) call.arguments : null); + sessionManager.onRecognitionConfigurationChanged(); + result.success(RecognizeConfigurationStore.toMap(configuration)); + } catch (IllegalArgumentException e) { + result.error("INVALID_RECOGNIZE_CONFIGURATION", e.getMessage(), null); + } catch (Exception e) { + result.error("SAVE_RECOGNIZE_CONFIGURATION_FAILED", e.getMessage(), null); + } + break; + case "resetRecognizeConfiguration": + try { + RecognizeConfiguration configuration = RecognizeConfigurationStore.reset(context); + sessionManager.onRecognitionConfigurationChanged(); + result.success(RecognizeConfigurationStore.toMap(configuration)); + } catch (Exception e) { + result.error("RESET_RECOGNIZE_CONFIGURATION_FAILED", e.getMessage(), null); + } + break; case "active": - String appId = Objects.requireNonNull(call.argument("appId")).toString(); - String activeKey = Objects.requireNonNull(call.argument("activeKey")).toString(); - String sdkKey = Objects.requireNonNull(call.argument("sdkKey")).toString(); + String appId = stringArgument(call, "appId"); + String activeKey = stringArgument(call, "activeKey"); + String sdkKey = stringArgument(call, "sdkKey"); Boolean active = FlutterTool.active(context, appId, activeKey, sdkKey); result.success(active); break; case "getConfig": - result.success(FlutterTool.getConfig()); + result.success(FlutterTool.getConfig(context)); + break; + case "getFaceStatus": + result.success(FlutterTool.getFaceStatus(context, stringArgument(call, "appId"), stringArgument(call, "activeKey"), stringArgument(call, "sdkKey"))); + break; + case "activateFaceSdk": + result.success(FlutterTool.activateFaceSdk(context, stringArgument(call, "appId"), stringArgument(call, "activeKey"), stringArgument(call, "sdkKey"))); break; case "registerAllStorageFace": String filePath = Objects.requireNonNull(call.argument("filePath")).toString(); @@ -105,9 +310,19 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me result.success(res); break; case "registerFaceByDatabase": - String userId = call.argument("userId"); - List errorSampleIdList = FlutterTool.registerFaceByDatabase(context, userId); - result.success(errorSampleIdList); + startRegistrationTask(call.argument("userId"), result, true); + break; + case "rebuildFaceLibrary": + startRegistrationTask(call.argument("userId"), result, false); + break; + case "extractFaceFeature": + startFaceFeatureExtraction(stringArgument(call, "filePath"), result); + break; + case "auditFaceQuality": + startFaceQualityAudit(call.argument("items"), result); + break; + case "applyFaceDelta": + startFaceDelta(call, result); break; case "getFace": String sampleId = Objects.requireNonNull(call.argument("sampleId")).toString(); @@ -123,31 +338,212 @@ public class FlutterFaceDiscernPlugin implements FlutterPlugin, MethodChannel.Me Boolean isRemove = FlutterTool.removeFace(context, Integer.parseInt(removeSampleId)); result.success(isRemove); break; + default: + result.notImplemented(); + break; } } + private void startRegistrationTask(String userId, MethodChannel.Result result, boolean legacyResult) { + if (!FlutterTool.tryStartRegistration()) { + result.error("REGISTRATION_IN_PROGRESS", "人脸库正在注册,请稍候", null); + return; + } + FACE_EXECUTOR.execute(() -> { + try { + Map registrationResult = FlutterTool.rebuildFaceLibrary(context, userId, this::emitRegistrationProgress); + MAIN_HANDLER.post(() -> { + if (legacyResult) { + result.success(registrationResult.get("failedSampleIds")); + } else { + result.success(registrationResult); + } + }); + } catch (Exception e) { + Log.e(TAG, "人脸库注册失败:" + e.getMessage(), e); + MAIN_HANDLER.post(() -> result.error("REGISTRATION_FAILED", "人脸库注册失败,请稍后重试", null)); + } finally { + FlutterTool.finishRegistration(); + } + }); + } + + /** + * 在统一人脸注册占用标记和后台执行器中提取临时图片特征。 + * + * @param filePath 临时图片绝对路径 + * @param result Flutter MethodChannel 异步结果 + */ + private void startFaceFeatureExtraction(String filePath, MethodChannel.Result result) { + if (!FlutterTool.tryStartRegistration()) { + result.error("REGISTRATION_IN_PROGRESS", "人脸库正在更新,请稍候", null); + return; + } + FACE_EXECUTOR.execute(() -> { + try { + Map extraction = FlutterTool.extractFaceFeature(context, filePath); + MAIN_HANDLER.post(() -> result.success(extraction)); + } catch (Exception exception) { + Log.e(TAG, "提取人脸特征失败:" + exception.getMessage(), exception); + MAIN_HANDLER.post(() -> result.error("EXTRACT_FACE_FEATURE_FAILED", "人脸特征提取失败", null)); + } finally { + FlutterTool.finishRegistration(); + } + }); + } + + /** + * 在统一人脸操作互斥锁内批量检测存量图片,并通过独立事件通道报告进度。 + * + * @param rawItems Flutter提交的样本编号与本地路径列表 + * @param result 批量检测完成结果 + */ + private void startFaceQualityAudit(Object rawItems, MethodChannel.Result result) { + if (!(rawItems instanceof List)) { + result.error("INVALID_QUALITY_AUDIT_ITEMS", "质量检测参数无效", null); + return; + } + if (!FlutterTool.tryStartRegistration()) { + result.error("REGISTRATION_IN_PROGRESS", "人脸库正在更新,请稍候", null); + return; + } + FACE_EXECUTOR.execute(() -> { + try { + Map audit = FlutterTool.auditFaceQuality( + context, (List) rawItems, this::emitQualityAuditProgress); + MAIN_HANDLER.post(() -> result.success(audit)); + } catch (Exception exception) { + Log.e(TAG, "在库人脸质量检测失败:" + exception.getMessage(), exception); + MAIN_HANDLER.post(() -> result.error("FACE_QUALITY_AUDIT_FAILED", "在库人脸质量检测失败", null)); + } finally { + FlutterTool.finishRegistration(); + } + }); + } + + /** + * 在统一人脸注册占用标记和后台执行器中应用单样本增删。 + * + * @param call 包含 operation、sampleId、用户元数据和可选特征字节的调用 + * @param result Flutter MethodChannel 异步结果 + */ + private void startFaceDelta(MethodCall call, MethodChannel.Result result) { + if (!FlutterTool.tryStartRegistration()) { + result.error("REGISTRATION_IN_PROGRESS", "人脸库正在更新,请稍候", null); + return; + } + FACE_EXECUTOR.execute(() -> { + try { + String operation = stringArgument(call, "operation"); + Number sampleIdValue = call.argument("sampleId"); + if (sampleIdValue == null) throw new IllegalArgumentException("sampleId不能为空"); + byte[] featureData = byteArrayArgument(call.argument("featureData")); + Map delta = FlutterTool.applyFaceDelta( + context, + operation, + sampleIdValue.intValue(), + stringArgument(call, "userId"), + stringArgument(call, "username"), + featureData + ); + MAIN_HANDLER.post(() -> result.success(delta)); + } catch (Exception exception) { + Log.e(TAG, "应用人脸增量失败:" + exception.getMessage(), exception); + MAIN_HANDLER.post(() -> result.error("APPLY_FACE_DELTA_FAILED", "人脸库增量更新失败", null)); + } finally { + FlutterTool.finishRegistration(); + } + }); + } + + /** + * 将 Flutter StandardMessageCodec 的数字列表转换为虹软特征字节。 + * + * @param value 可为空的 byte[] 或数字列表 + * @return 特征字节;参数为空时返回空数组 + */ + private byte[] byteArrayArgument(Object value) { + if (value instanceof byte[]) return (byte[]) value; + if (!(value instanceof List)) return new byte[0]; + List values = (List) value; + byte[] result = new byte[values.size()]; + for (int index = 0; index < values.size(); index++) { + Object item = values.get(index); + if (!(item instanceof Number)) throw new IllegalArgumentException("featureData包含非数字值"); + result[index] = ((Number) item).byteValue(); + } + return result; + } + + private void emitRegistrationProgress(Map progress) { + MAIN_HANDLER.post(() -> { + EventChannel.EventSink sink = registrationEventSink; + if (sink != null) sink.success(progress); + }); + } + + /** 将质量检测进度安全切回主线程发送给Flutter。 */ + private void emitQualityAuditProgress(Map progress) { + MAIN_HANDLER.post(() -> { + EventChannel.EventSink sink = qualityAuditEventSink; + if (sink != null) sink.success(progress); + }); + } + + private String stringArgument(MethodCall call, String key) { + Object value = call.argument(key); + return value == null ? "" : value.toString(); + } + // flutter回调监听 @Override public void onListen(Object arguments, EventChannel.EventSink events) { - Log.e(TAG, "flutter回调监听进入"); + faceEventSink = events; + unregisterLegacyReceiver(); try { - IntentFilter intentFilter = new IntentFilter(); - intentFilter.addAction(FaceBrodConstant.FACE_GLOBAL_INTENT); + IntentFilter intentFilter = new IntentFilter(FaceBrodConstant.FACE_GLOBAL_INTENT); flutterFaceReceiver = new FlutterFaceReceiver(events); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { context.registerReceiver(flutterFaceReceiver, intentFilter, Context.RECEIVER_EXPORTED); } else { context.registerReceiver(flutterFaceReceiver, intentFilter); } - isReceiverRegistered = true; - } catch (Exception e) { - Log.e(TAG, "flutter回调监听错误error:" + e.getMessage(), e); + receiverRegistered = true; + } catch (Exception error) { + Log.e(TAG, "注册旧人脸 Activity 回调失败:" + error.getMessage(), error); } } @Override public void onCancel(Object arguments) { - context.unregisterReceiver(flutterFaceReceiver); + faceEventSink = null; + unregisterLegacyReceiver(); + } + + private void unregisterLegacyReceiver() { + if (flutterFaceReceiver != null && receiverRegistered) { + try { + context.unregisterReceiver(flutterFaceReceiver); + } catch (Exception ignored) { + } + } flutterFaceReceiver = null; + receiverRegistered = false; + } + + @Override + public void onSessionEvent(Map event) { + MAIN_HANDLER.post(() -> { + EventChannel.EventSink sink = sessionEventSink; + if (sink != null) sink.success(event); + }); + } + + @Override + public void onRecognitionResult(String json) { + MAIN_HANDLER.post(() -> { + EventChannel.EventSink sink = faceEventSink; + if (sink != null) sink.success(json); + }); } } diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceSubScreenPlugin.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceSubScreenPlugin.java new file mode 100644 index 0000000..f28f9f0 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterFaceSubScreenPlugin.java @@ -0,0 +1,44 @@ +package io.flutter.plugins.facediscern.flutter; + +import androidx.annotation.NonNull; + +import io.flutter.embedding.engine.FlutterEngine; +import io.flutter.embedding.engine.plugins.FlutterPlugin; + +/** + * flutter_subscreen_plugin 会复用主屏 Registry 中的插件对象。 + * 该桥接器忽略主屏重复附着,仅在发现不同的副屏 Engine 时创建独立插件端点, + * 避免两个 Engine 覆盖彼此的 MethodChannel、EventSink 和 TextureRegistry。 + */ +public final class FlutterFaceSubScreenPlugin implements FlutterPlugin { + private FlutterEngine mainEngine; + private FlutterEngine subEngine; + private FlutterFaceDiscernPlugin subDelegate; + private FlutterPluginBinding subBinding; + + @Override + public synchronized void onAttachedToEngine(@NonNull FlutterPluginBinding binding) { + FlutterEngine engine = binding.getFlutterEngine(); + if (mainEngine == null) { + mainEngine = engine; + return; + } + if (engine == mainEngine || engine == subEngine) return; + subEngine = engine; + subBinding = binding; + subDelegate = new FlutterFaceDiscernPlugin(); + subDelegate.onAttachedToEngine(binding); + } + + @Override + public synchronized void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + FlutterEngine engine = binding.getFlutterEngine(); + if (engine == subEngine && subDelegate != null && subBinding != null) { + subDelegate.onDetachedFromEngine(subBinding); + subDelegate = null; + subBinding = null; + subEngine = null; + } + if (engine == mainEngine) mainEngine = null; + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java index 534fc99..7646083 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/FlutterTool.java @@ -1,6 +1,7 @@ package io.flutter.plugins.facediscern.flutter; import static com.arcsoft.face.FaceEngine.activeOnline; +import static com.arcsoft.face.FaceEngine.getActiveFileInfo; import static io.flutter.plugins.facediscern.database.DataBaseUtils.getUserFace; import static io.flutter.plugins.facediscern.database.DataBaseUtils.updateFaceModel; @@ -11,6 +12,7 @@ import android.graphics.BitmapFactory; import io.flutter.Log; +import com.arcsoft.face.ActiveFileInfo; import com.arcsoft.face.ErrorInfo; import com.arcsoft.face.FaceFeatureInfo; import com.arcsoft.imageutil.ArcSoftImageFormat; @@ -22,10 +24,13 @@ import java.io.File; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.stream.Collectors; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; import io.flutter.plugins.facediscern.flutter.config.Constants; import io.flutter.plugins.facediscern.tool.face.model.FaceEntity; @@ -38,49 +43,127 @@ public class FlutterTool { static private final String FACE_JSON_FILE_NAME = "face.json"; + private static final int REGISTER_BATCH_SIZE = 100; + private static final AtomicBoolean REGISTRATION_IN_PROGRESS = new AtomicBoolean(false); + private static volatile Map latestRegistrationProgress = idleRegistrationProgress(); + private static volatile Map latestRegistrationResult; + private static volatile Map latestQualityAuditProgress = idleQualityAuditProgress(); + + public interface RegistrationProgressListener { + void onProgress(Map progress); + } + + /** 接收原生在库质量检测进度并转发给Flutter事件通道。 */ + public interface QualityAuditProgressListener { + /** 发布一份不包含图片路径和人员信息的进度快照。 */ + void onProgress(Map progress); + } + + static void updateConfig(String appId, String activeKey, String sdkKey) { + Constants.APP_ID = appId == null ? "" : appId.trim(); + Constants.ACTIVE_KEY = activeKey == null ? "" : activeKey.trim(); + Constants.SDK_KEY = sdkKey == null ? "" : sdkKey.trim(); + } + // 在线激活 static public Boolean active(Context context, String appId, String activeKey, String sdkKey) { - if (Objects.isNull(appId)) { - Log.e(TAG, "请传入appId"); - return false; + Map result = activateFaceSdk(context, appId, activeKey, sdkKey); + return Boolean.TRUE.equals(result.get("success")); + } + + static public Map activateFaceSdk(Context context, String appId, String activeKey, String sdkKey) { + updateConfig(appId, activeKey, sdkKey); + Map result = new HashMap<>(); + if (!hasCompleteConfig()) { + result.put("success", false); + result.put("code", ErrorInfo.MERR_INVALID_PARAM); + result.put("message", "人脸插件参数不完整,请先同步设备配置"); + result.put("requiresPermission", false); + result.put("canOpenSettings", false); + return result; } - if (Objects.isNull(activeKey)) { - Log.e(TAG, "请传入activeKey"); - return false; - } - if (Objects.isNull(sdkKey)) { - Log.e(TAG, "请传入sdkKey"); - return false; - } - Constants.APP_ID = appId; - Constants.ACTIVE_KEY = activeKey; - Constants.SDK_KEY = sdkKey; int code = activeOnline(context, Constants.ACTIVE_KEY, Constants.APP_ID, Constants.SDK_KEY); - switch (code) { - case ErrorInfo.MOK: - Log.i(TAG, "激活成功!"); - break; - case ErrorInfo.MERR_ASF_ALREADY_ACTIVATED: - Log.i(TAG, "已激活!"); - break; - default: - Log.e(TAG, "激活失败!code:" + code); - } boolean flag = Objects.equals(code, ErrorInfo.MOK) || Objects.equals(code, ErrorInfo.MERR_ASF_ALREADY_ACTIVATED); Constants.IS_ACTIVE = flag; - return flag; + result.put("success", flag); + result.put("code", code); + result.put("message", flag ? "人脸插件激活成功" : activationErrorMessage(code)); + boolean requiresPermission = code == ErrorInfo.MERR_ASF_READ_PHONE_STATE_DENIED; + result.put("requiresPermission", requiresPermission); + result.put("canOpenSettings", requiresPermission); + Log.i(TAG, "人脸插件激活结果code:" + code + ";success:" + flag); + return result; } // 获取配置 - static Map getConfig() { + static Map getConfig(Context context) { HashMap config = new HashMap<>(); config.put("appId", Constants.APP_ID); config.put("sdkKey", Constants.SDK_KEY); config.put("activeKey", Constants.ACTIVE_KEY); - config.put("isActive", Constants.IS_ACTIVE); + config.put("isActive", isActivated(context)); return config; } + static Map getFaceStatus(Context context, String appId, String activeKey, String sdkKey) { + updateConfig(appId, activeKey, sdkKey); + Map status = new HashMap<>(); + ActiveFileInfo activeFileInfo = new ActiveFileInfo(); + int activeCode = getActiveFileInfo(context, activeFileInfo); + boolean activated = activeCode == ErrorInfo.MOK; + Constants.IS_ACTIVE = activated; + + FaceServer faceServer = FaceServer.getInstance(); + if (activated) faceServer.init(context); + List databaseFaces = getUserFace(context, null); + int databaseCount = databaseFaces.size(); + int memoryCount = activated && faceServer.isEngineReady() ? Math.max(faceServer.getTotalFaceCount(), 0) : 0; + boolean registrationInProgress = REGISTRATION_IN_PROGRESS.get(); + Set databaseSampleIds = new HashSet<>(); + for (FaceEntity face : databaseFaces) { + if (face.getSampleId() != null) databaseSampleIds.add(face.getSampleId()); + } + Set memorySampleIds = activated && faceServer.isEngineReady() + ? faceServer.getRegisteredFaceIds() + : new HashSet<>(); + + status.put("activated", activated); + status.put("activationCode", activeCode); + status.put("parametersComplete", hasCompleteConfig()); + status.put("activationStartTime", activated ? activeFileInfo.getStartTime() : ""); + status.put("activationEndTime", activated ? activeFileInfo.getEndTime() : ""); + status.put("engineReady", faceServer.isEngineReady()); + status.put("engineCode", faceServer.getEngineInitCode()); + status.put("databaseCount", databaseCount); + status.put("memoryCount", memoryCount); + status.put("registrationInProgress", registrationInProgress); + status.put("libraryConsistent", !registrationInProgress && databaseSampleIds.equals(memorySampleIds)); + status.put("registrationProgress", new HashMap<>(latestRegistrationProgress)); + status.put("lastRegistrationResult", latestRegistrationResult == null ? null : new HashMap<>(latestRegistrationResult)); + return status; + } + + static boolean isRegistrationInProgress() { + return REGISTRATION_IN_PROGRESS.get(); + } + + static boolean tryStartRegistration() { + return REGISTRATION_IN_PROGRESS.compareAndSet(false, true); + } + + static void finishRegistration() { + REGISTRATION_IN_PROGRESS.set(false); + } + + static Map getLatestRegistrationProgress() { + return new HashMap<>(latestRegistrationProgress); + } + + /** 返回最新在库图片质量检测进度,供新订阅者立即恢复页面状态。 */ + static Map getLatestQualityAuditProgress() { + return new HashMap<>(latestQualityAuditProgress); + } + static Integer parseId(String id) { try { @@ -168,29 +251,38 @@ public class FlutterTool { static public Boolean registerFace(Context context, byte[] bytes, FaceEntity entity) { FaceServer faceServer = FaceServer.getInstance(); faceServer.init(context); - // 类型转换 Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + if (bitmap == null) return false; Bitmap alignedBitmap = ArcSoftImageUtil.getAlignedBitmap(bitmap, true); - byte[] bgr24Data = ArcSoftImageUtil.createImageData(alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24); - int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24); - if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) { - Log.e(TAG, "注册人脸,bitmapToImageData错误code:" + transformCode); + if (alignedBitmap == null) { + bitmap.recycle(); return false; } - byte[] faceBytes = faceServer.registerBgr24( - context, - bgr24Data, - alignedBitmap.getWidth(), - alignedBitmap.getHeight(), - String.valueOf(System.currentTimeMillis()) - ); - if (faceBytes == null) { - Log.e(TAG, "注册人脸,registerBgr24错误"); - return false; + try { + byte[] bgr24Data = ArcSoftImageUtil.createImageData(alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24); + int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24); + if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) { + Log.e(TAG, "注册人脸,bitmapToImageData错误code:" + transformCode); + return false; + } + byte[] faceBytes = faceServer.registerBgr24( + context, + bgr24Data, + alignedBitmap.getWidth(), + alignedBitmap.getHeight(), + String.valueOf(System.currentTimeMillis()) + ); + if (faceBytes == null) { + Log.e(TAG, "注册人脸,registerBgr24错误"); + return false; + } + entity.setFaceModel(faceBytes); + int code = faceServer.registerFaceFeatureInfoFromDb(entity); + return code == ErrorInfo.MOK; + } finally { + if (alignedBitmap != bitmap && !alignedBitmap.isRecycled()) alignedBitmap.recycle(); + if (!bitmap.isRecycled()) bitmap.recycle(); } - entity.setFaceModel(faceBytes); - int code = faceServer.registerFaceFeatureInfoFromDb(entity); - return code == ErrorInfo.MOK; } @@ -222,50 +314,417 @@ public class FlutterTool { return i == 0; } + /** + * 从临时图片提取虹软注册特征,不修改 SQLite 或虹软内存人脸库。 + * + * @param context Android 应用上下文 + * @param filePath 同步服务写入的临时图片绝对路径 + * @return 包含成功标记、安全摘要和特征字节的 MethodChannel 结果 + */ + static public Map extractFaceFeature(Context context, String filePath) { + Map result = new HashMap<>(); + try { + FaceServer.QualityFeatureResult nativeResult = extractFaceResultFromFile(context, filePath); + byte[] featureData = nativeResult.featureData; + boolean success = featureData != null && featureData.length > 0; + result.put("success", success); + result.put("message", success ? "人脸特征提取成功" : "图片中未提取到可注册人脸"); + result.put("featureData", success ? featureData : new byte[0]); + result.put("qualityStatus", nativeResult.qualityStatus); + result.put("qualityScore", nativeResult.qualityScore); + result.put("qualityErrorCode", nativeResult.qualityErrorCode); + return result; + } catch (Exception exception) { + Log.e(TAG, "提取人脸特征异常error:" + exception.getMessage(), exception); + result.put("success", false); + result.put("message", "人脸特征提取失败"); + result.put("featureData", new byte[0]); + result.put("qualityStatus", "failed"); + result.put("qualityErrorCode", -99); + return result; + } + } - // 批量注册全部人脸 - static public List registerFaceByDatabase(Context context, String userId) { - List faceList = getUserFace(context, userId); - List errorSampleIdList = new ArrayList<>(); - if (faceList.isEmpty()) return errorSampleIdList; - long startTime = System.currentTimeMillis(); - // 批量查询人脸模型 - for (FaceEntity v : faceList) { - if (v.getFaceModel() != null && v.getFaceModel().length > 0) continue; - byte[] bytes = null; - try { - bytes = FileUtil.fileToData(new File(v.getFaceFilePath())); - } catch (Exception e) { - errorSampleIdList.add(v.getSampleId()); - Log.e(TAG, "registerFaceByDatabase,方法错误,file读取错误error:" + e.getMessage() + "json:" + new Gson().toJson(v), e); - continue; - } - if (bytes == null || bytes.length == 0) { - errorSampleIdList.add(v.getSampleId()); - Log.e(TAG, "registerFaceByDatabase,方法错误" + v.getUserName() + "bytes is null," + "json:" + new Gson().toJson(v)); - continue; - } - Boolean isRegister = registerFace(context, bytes, v); - if (isRegister) { - updateFaceModel(context, v); + /** + * 批量检测Flutter提交的本地人脸图片质量,逐图释放Bitmap并返回一一对应的结果。 + */ + static public Map auditFaceQuality(Context context, List items, QualityAuditProgressListener listener) { + List> results = new ArrayList<>(); + int total = items == null ? 0 : items.size(); + int success = 0; + int failed = 0; + publishQualityAuditProgress(listener, 0, total, success, failed, null, true, "准备检测在库人脸"); + if (items != null) { + for (Object rawItem : items) { + Map itemResult = new HashMap<>(); + String sampleId = ""; + try { + if (!(rawItem instanceof Map)) throw new IllegalArgumentException("检测项格式错误"); + Map item = (Map) rawItem; + sampleId = item.get("sampleId") == null ? "" : item.get("sampleId").toString(); + String filePath = item.get("filePath") == null ? "" : item.get("filePath").toString(); + FaceServer.QualityFeatureResult quality = detectFaceQualityFromFile(context, filePath); + itemResult.put("sampleId", sampleId); + itemResult.put("status", quality.qualityStatus); + itemResult.put("score", quality.qualityScore); + itemResult.put("errorCode", quality.qualityErrorCode); + boolean detected = "detected".equals(quality.qualityStatus) && quality.qualityScore != null; + itemResult.put("message", detected ? "图片质量检测成功" : "图片质量检测失败"); + if (detected) success++; else failed++; + } catch (Exception exception) { + Log.w(TAG, "在库图片质量检测失败sampleId:" + maskSampleIdText(sampleId) + ";error:" + exception.getClass().getSimpleName()); + itemResult.put("sampleId", sampleId); + itemResult.put("status", "failed"); + itemResult.put("errorCode", -99); + itemResult.put("message", "图片质量检测失败"); + failed++; + } + results.add(itemResult); + publishQualityAuditProgress(listener, results.size(), total, success, failed, sampleId, true, "正在检测在库人脸"); } } + publishQualityAuditProgress(listener, total, total, success, failed, null, false, "在库人脸质量检测完成"); + Map auditResult = new HashMap<>(); + auditResult.put("results", results); + auditResult.put("message", "在库人脸质量检测完成"); + return auditResult; + } + + /** + * 在虹软内存库中应用单样本增删;替换失败由 FaceServer 恢复旧特征。 + * + * @param context Android 应用上下文 + * @param operation upsert 或 delete + * @param sampleId 虹软 32 位搜索标识 + * @param userId 用户主键,仅 upsert 使用 + * @param username 用户姓名,仅作为内存识别元数据 + * @param featureData 已通过提取校验的特征字节,仅 upsert 使用 + * @return 包含成功标记、SDK 返回码和安全摘要的结果 + */ + static public Map applyFaceDelta(Context context, String operation, int sampleId, + String userId, String username, byte[] featureData) { + Map result = new HashMap<>(); FaceServer faceServer = FaceServer.getInstance(); faceServer.init(context); - if (errorSampleIdList.isEmpty()) { - try { - faceServer.registerFaceFeatureInfoListFromDb(faceList); - Log.d(TAG, "registerFaceByDatabase,注册全部人脸耗时:" + (System.currentTimeMillis() - startTime) + "毫秒"); - } catch (Exception e) { - faceServer.removeFace(-1); - Log.e(TAG, "registerFaceByDatabase,人脸引擎批量注册失败使用单个注册"); - } + int code; + if ("delete".equals(operation)) { + code = faceServer.removeFace(sampleId); + } else if ("upsert".equals(operation) && featureData != null && featureData.length > 0) { + FaceEntity entity = new FaceEntity(); + entity.setFaceId(sampleId); + entity.setSampleId(sampleId); + entity.setUserId(userId); + entity.setUserName(username); + entity.setFaceModel(featureData); + code = faceServer.replaceFaceFeature(entity); } else { - for (FaceEntity v : faceList) { - faceServer.registerFaceFeatureInfoFromDb(v); - } - Log.d(TAG, "registerFaceByDatabase,注册单个人脸耗时:" + (System.currentTimeMillis() - startTime) + "毫秒"); + code = ErrorInfo.MERR_INVALID_PARAM; } - return errorSampleIdList; + boolean success = code == ErrorInfo.MOK; + result.put("success", success); + result.put("code", code); + result.put("message", success ? "人脸库增量更新成功" : "人脸库增量更新失败"); + return result; + } + + + // 旧通道保留同步包装;新页面通过 rebuildFaceLibrary 获取完整结果和进度。 + static public List registerFaceByDatabase(Context context, String userId) { + Map result = rebuildFaceLibrary(context, userId, null); + Object failed = result.get("failedSampleIds"); + if (failed instanceof List) { + //noinspection unchecked + return (List) failed; + } + return new ArrayList<>(); + } + + static public Map rebuildFaceLibrary(Context context, String userId, RegistrationProgressListener listener) { + long startTime = System.currentTimeMillis(); + List faceList = getUserFace(context, userId); + Set failedSampleIds = new LinkedHashSet<>(); + List validFaces = new ArrayList<>(); + int total = faceList.size(); + int processed = 0; + + publishProgress(listener, "preparing", "准备人脸特征", processed, total, 0, 0, null, true); + for (FaceEntity face : faceList) { + boolean valid = hasFaceModel(face); + if (!valid) { + valid = extractAndPersistFaceModel(context, face); + } + processed++; + if (valid) { + validFaces.add(face); + } else if (face.getSampleId() != null) { + failedSampleIds.add(face.getSampleId()); + } + publishProgress(listener, "preparing", "生成缺失的人脸特征", processed, total, validFaces.size(), failedSampleIds.size(), face.getSampleId(), true); + } + + FaceServer faceServer = FaceServer.getInstance(); + faceServer.init(context); + if (!faceServer.isEngineReady()) { + for (FaceEntity face : validFaces) { + if (face.getSampleId() != null) failedSampleIds.add(face.getSampleId()); + } + return finishRegistrationResult(listener, total, 0, failedSampleIds, "人脸引擎初始化失败,请重新激活后再试", startTime); + } + + boolean fullRebuild = userId == null || userId.trim().isEmpty(); + if (fullRebuild) { + faceServer.removeFace(-1); + } else { + faceServer.removeFacesByUserId(userId); + } + + int preparationFailureCount = failedSampleIds.size(); + processed = 0; + int success = 0; + for (int start = 0; start < validFaces.size(); start += REGISTER_BATCH_SIZE) { + int end = Math.min(start + REGISTER_BATCH_SIZE, validFaces.size()); + List batch = new ArrayList<>(validFaces.subList(start, end)); + int code; + try { + code = faceServer.registerFaceFeatureInfoBatchFromDb(batch); + } catch (Exception exception) { + code = -99; + Log.e(TAG, "批量注册抛出异常,改为逐条处理error:" + exception.getMessage(), exception); + } + if (code == ErrorInfo.MOK) { + success += batch.size(); + processed += batch.size(); + Integer currentSampleId = batch.get(batch.size() - 1).getSampleId(); + publishProgress(listener, "registering", "装载人脸到内存库", preparationFailureCount + processed, total, success, failedSampleIds.size(), currentSampleId, true); + continue; + } + + Log.w(TAG, "批量注册失败code:" + code + ";改为逐条定位失败样本"); + for (FaceEntity face : batch) { + int singleCode; + try { + singleCode = faceServer.registerFaceFeatureInfoFromDb(face); + } catch (Exception exception) { + singleCode = -99; + Log.e(TAG, "单条注册异常sampleId:" + maskSampleId(face.getSampleId()) + ";error:" + exception.getMessage(), exception); + } + if (singleCode == ErrorInfo.MOK) { + success++; + } else if (face.getSampleId() != null) { + failedSampleIds.add(face.getSampleId()); + } + processed++; + publishProgress(listener, "registering", "逐条定位异常样本", preparationFailureCount + processed, total, success, failedSampleIds.size(), face.getSampleId(), true); + } + } + + String message; + if (total == 0) { + message = "本地数据库暂无人脸样本"; + } else if (failedSampleIds.isEmpty()) { + message = "人脸库注册完成"; + } else { + message = "人脸库注册完成,部分样本失败,请前往人脸管理处理"; + } + return finishRegistrationResult(listener, total, success, failedSampleIds, message, startTime); + } + + private static boolean extractAndPersistFaceModel(Context context, FaceEntity face) { + if (face == null || face.getFaceFilePath() == null) return false; + try { + byte[] faceBytes = extractFaceModelFromFile(context, face.getFaceFilePath()); + if (faceBytes == null || faceBytes.length == 0) return false; + face.setFaceModel(faceBytes); + return updateFaceModel(context, face); + } catch (Exception e) { + Log.e(TAG, "生成人脸特征失败sampleId:" + maskSampleId(face.getSampleId()) + ";error:" + e.getMessage()); + return false; + } + } + + /** + * 解码图片并在统一 FaceServer 引擎锁内提取注册特征,不写入业务数据库。 + * + * @param context Android 应用上下文 + * @param filePath 待校验图片绝对路径 + * @return 虹软特征字节,图片无效或未检测到合格人脸时返回空 + */ + private static byte[] extractFaceModelFromFile(Context context, String filePath) { + return extractFaceResultFromFile(context, filePath).featureData; + } + + /** 将本地文件一次转换为BGR24并同时返回特征与质量结果。 */ + private static FaceServer.QualityFeatureResult extractFaceResultFromFile(Context context, String filePath) { + FaceServer.QualityFeatureResult failed = new FaceServer.QualityFeatureResult(); + failed.qualityStatus = "failed"; + failed.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM; + if (filePath == null || filePath.isEmpty()) return failed; + byte[] bytes = FileUtil.fileToData(new File(filePath)); + if (bytes == null || bytes.length == 0) return failed; + Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + if (bitmap == null) return failed; + Bitmap alignedBitmap = ArcSoftImageUtil.getAlignedBitmap(bitmap, true); + if (alignedBitmap == null) { + bitmap.recycle(); + return failed; + } + try { + byte[] bgr24Data = ArcSoftImageUtil.createImageData( + alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24); + int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24); + if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) { + failed.qualityErrorCode = transformCode; + return failed; + } + FaceServer faceServer = FaceServer.getInstance(); + faceServer.init(context); + return faceServer.registerBgr24WithQuality(context, bgr24Data, alignedBitmap.getWidth(), + alignedBitmap.getHeight(), String.valueOf(System.currentTimeMillis())); + } finally { + if (alignedBitmap != bitmap && !alignedBitmap.isRecycled()) alignedBitmap.recycle(); + if (!bitmap.isRecycled()) bitmap.recycle(); + } + } + + /** 将本地文件转换为BGR24后仅执行质量检测。 */ + private static FaceServer.QualityFeatureResult detectFaceQualityFromFile(Context context, String filePath) { + FaceServer.QualityFeatureResult failed = new FaceServer.QualityFeatureResult(); + failed.qualityStatus = "failed"; + failed.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM; + if (filePath == null || filePath.isEmpty()) return failed; + byte[] bytes = FileUtil.fileToData(new File(filePath)); + if (bytes == null || bytes.length == 0) return failed; + Bitmap bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + if (bitmap == null) return failed; + Bitmap alignedBitmap = ArcSoftImageUtil.getAlignedBitmap(bitmap, true); + if (alignedBitmap == null) { + bitmap.recycle(); + return failed; + } + try { + byte[] bgr24Data = ArcSoftImageUtil.createImageData(alignedBitmap.getWidth(), alignedBitmap.getHeight(), ArcSoftImageFormat.BGR24); + int transformCode = ArcSoftImageUtil.bitmapToImageData(alignedBitmap, bgr24Data, ArcSoftImageFormat.BGR24); + if (transformCode != ArcSoftImageUtilError.CODE_SUCCESS) { + failed.qualityErrorCode = transformCode; + return failed; + } + FaceServer faceServer = FaceServer.getInstance(); + faceServer.init(context); + return faceServer.detectBgr24Quality(bgr24Data, alignedBitmap.getWidth(), alignedBitmap.getHeight()); + } finally { + if (alignedBitmap != bitmap && !alignedBitmap.isRecycled()) alignedBitmap.recycle(); + if (!bitmap.isRecycled()) bitmap.recycle(); + } + } + + /** 发布并缓存质量检测进度,供页面中途进入时恢复。 */ + private static void publishQualityAuditProgress(QualityAuditProgressListener listener, int processed, int total, + int success, int failed, String currentSampleId, + boolean inProgress, String message) { + Map progress = new HashMap<>(); + progress.put("processed", processed); + progress.put("total", total); + progress.put("successCount", success); + progress.put("failedCount", failed); + progress.put("currentSampleId", currentSampleId); + progress.put("inProgress", inProgress); + progress.put("message", message); + latestQualityAuditProgress = progress; + if (listener != null) listener.onProgress(new HashMap<>(progress)); + } + + /** 创建尚未执行质量检测时的空进度快照。 */ + private static Map idleQualityAuditProgress() { + Map progress = new HashMap<>(); + progress.put("processed", 0); + progress.put("total", 0); + progress.put("successCount", 0); + progress.put("failedCount", 0); + progress.put("currentSampleId", null); + progress.put("inProgress", false); + progress.put("message", "尚未执行在库人脸质量检测"); + return progress; + } + + /** 对质量检测日志中的样本编号做最小脱敏。 */ + private static String maskSampleIdText(String sampleId) { + if (sampleId == null || sampleId.isEmpty()) return "-"; + if (sampleId.length() <= 4) return "***" + sampleId; + return sampleId.substring(0, 1) + "***" + sampleId.substring(sampleId.length() - 3); + } + + private static boolean hasFaceModel(FaceEntity face) { + return face != null && face.getFaceModel() != null && face.getFaceModel().length > 0; + } + + private static boolean hasCompleteConfig() { + return !Constants.APP_ID.isEmpty() && !Constants.SDK_KEY.isEmpty() && !Constants.ACTIVE_KEY.isEmpty(); + } + + private static boolean isActivated(Context context) { + ActiveFileInfo activeFileInfo = new ActiveFileInfo(); + boolean activated = getActiveFileInfo(context, activeFileInfo) == ErrorInfo.MOK; + Constants.IS_ACTIVE = activated; + return activated; + } + + private static Map idleRegistrationProgress() { + Map progress = new HashMap<>(); + progress.put("phase", "idle"); + progress.put("message", "尚未执行人脸库注册"); + progress.put("processed", 0); + progress.put("total", 0); + progress.put("successCount", 0); + progress.put("failedCount", 0); + progress.put("currentSampleId", null); + progress.put("inProgress", false); + return progress; + } + + private static void publishProgress(RegistrationProgressListener listener, String phase, String message, int processed, int total, int success, int failed, Integer currentSampleId, boolean inProgress) { + Map progress = new HashMap<>(); + progress.put("phase", phase); + progress.put("message", message); + progress.put("processed", processed); + progress.put("total", total); + progress.put("successCount", success); + progress.put("failedCount", failed); + progress.put("currentSampleId", currentSampleId); + progress.put("inProgress", inProgress); + latestRegistrationProgress = progress; + if (listener != null) listener.onProgress(new HashMap<>(progress)); + } + + private static Map finishRegistrationResult(RegistrationProgressListener listener, int total, int success, Set failedSampleIds, String message, long startTime) { + List failed = new ArrayList<>(failedSampleIds); + Map result = new HashMap<>(); + result.put("success", failed.isEmpty() && success == total); + result.put("message", message); + result.put("total", total); + result.put("successCount", success); + result.put("failedCount", failed.size()); + result.put("failedSampleIds", failed); + result.put("durationMs", System.currentTimeMillis() - startTime); + latestRegistrationResult = result; + publishProgress(listener, failed.isEmpty() ? "completed" : "completedWithErrors", message, total, total, success, failed.size(), null, false); + Log.i(TAG, "人脸库注册结束total:" + total + ";success:" + success + ";failed:" + failed.size()); + return result; + } + + private static String maskSampleId(Integer sampleId) { + if (sampleId == null) return "-"; + String value = String.valueOf(sampleId); + if (value.length() <= 4) return "***" + value; + return value.substring(0, 1) + "***" + value.substring(value.length() - 3); + } + + private static String activationErrorMessage(int code) { + if (code == ErrorInfo.MERR_ASF_READ_PHONE_STATE_DENIED) return "缺少电话状态权限,请授权后重试"; + if (code == ErrorInfo.MERR_ASF_INTERNET_DENIED || code == ErrorInfo.MERR_ASF_NETWORK_COULDNT_RESOLVE_HOST || code == ErrorInfo.MERR_ASF_NETWORK_COULDNT_CONNECT_SERVER || code == ErrorInfo.MERR_ASF_NETWORK_CONNECT_TIMEOUT || code == ErrorInfo.MERR_ASF_NETWORK_UNKNOWN_ERROR) return "无法连接虹软激活服务,请检查网络后重试"; + if (code == ErrorInfo.MERR_ASF_ACTIVEKEY_PARM_MISMATCH || code == ErrorInfo.MERR_ASF_ACTIVEKEY_APPID_PARM_MISMATCH || code == ErrorInfo.MERR_ASF_ACTIVEKEY_SDK_FILE_MISMATCH || code == ErrorInfo.MERR_ASF_APPID_APPKEY_SDK_MISMATCH || code == ErrorInfo.MERR_FSDK_INVALID_ID_PAIR || code == ErrorInfo.MERR_FSDK_MISMATCH_ID_AND_SDK) return "激活参数不匹配,请重新同步设备配置"; + if (code == ErrorInfo.MERR_ASF_ACTIVEKEY_EXPIRED || code == ErrorInfo.MERR_EXPIRED || code == ErrorInfo.MERR_ASF_LICENSE_FILE_EXPIRED) return "人脸插件授权已过期,请联系管理员更新授权"; + if (code == ErrorInfo.MERR_ASF_LOCAL_TIME_NOT_CALIBRATED || code == ErrorInfo.MERR_ASF_CURRENT_DEVICE_TIME_INCORRECT) return "设备时间异常,请校准系统时间后重试"; + if (code == ErrorInfo.MERR_ASF_DEVICE_MISMATCH) return "授权与当前设备不匹配,请联系管理员重新绑定"; + return "人脸插件激活失败,错误码:" + code; } } diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/config/Constants.java b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/config/Constants.java index 1c234e7..2a305b8 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/config/Constants.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/flutter/config/Constants.java @@ -2,8 +2,12 @@ package io.flutter.plugins.facediscern.flutter.config; public class Constants { - public static String APP_ID = "FWKhBhtgnUnVrUJXZD3SWqnGYV99AiXP4SS8aNSR6bk3"; - public static String SDK_KEY = "5SAziGjCHYPY4GdjGY8dTiBABBmuLbiTp7oWsKTcWzMb"; - public static String ACTIVE_KEY = "U571-11UF-F13U-F43A"; + public static String APP_ID = ""; + public static String SDK_KEY = ""; + public static String ACTIVE_KEY = ""; + + /** + * 仅保留给旧版 getConfig 通道兼容,真实授权状态必须通过 ActiveFileInfo 查询。 + */ public static boolean IS_ACTIVE = false; } diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceEvidenceImageWriter.java b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceEvidenceImageWriter.java new file mode 100644 index 0000000..41331ad --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceEvidenceImageWriter.java @@ -0,0 +1,251 @@ +package io.flutter.plugins.facediscern.session; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Matrix; +import android.graphics.Rect; +import android.graphics.YuvImage; +import android.graphics.ImageFormat; + +import com.arcsoft.face.FaceInfo; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +import io.flutter.plugins.facediscern.tool.face.model.RecognitionFrameSnapshot; + +/** + * 将人脸比对成功帧裁剪为终端私有 JPEG 证据。 + * + *

裁剪使用虹软实际参与比对的人脸框并外扩约 20%,随后补偿相机方向和镜像,最长边 + * 限制为 640 像素,质量逐级降低到约 100KB。文件写入使用临时文件改名,避免任务队列 + * 看到半个 JPEG。

+ */ +public final class FaceEvidenceImageWriter { + /** 本地现场图最长边上限,单位为像素。 */ + private static final int MAX_EDGE = 640; + + /** 本地现场图目标大小,单位为字节。 */ + private static final int TARGET_BYTES = 100 * 1024; + + /** 人脸框外扩比例。 */ + private static final float PADDING_RATIO = 0.20f; + + /** 创建人脸证据写入器。 */ + private FaceEvidenceImageWriter() { + } + + /** + * 写入一张成功匹配目标的人脸现场图。 + * + * @param contextFilesDir 应用私有 files 目录 + * @param snapshot 实际提取特征所用的帧和目标框 + * @param rotation 相机预览方向,取 0/90/180/270 + * @param mirror 是否需要水平镜像 + * @param trackId 虹软跟踪编号,仅用于生成不含业务信息的文件名 + * @return 图片写入结果;失败时包含安全错误摘要且路径为空 + */ + public static Result write(File contextFilesDir, RecognitionFrameSnapshot snapshot, int rotation, + boolean mirror, int trackId) { + try { + if (snapshot == null || snapshot.getNv21() == null || snapshot.getFaceInfo() == null) { + return Result.failure(snapshot == null ? 0L : snapshot.getCapturedAt(), "成功帧快照为空"); + } + byte[] nv21 = snapshot.getNv21(); + FaceInfo faceInfo = snapshot.getFaceInfo(); + Rect crop = expandAndClamp(faceInfo.getRect(), snapshot.getWidth(), snapshot.getHeight()); + if (crop.width() < 2 || crop.height() < 2) return Result.failure(snapshot.getCapturedAt(), "人脸框无效"); + ByteArrayOutputStream rawJpeg = new ByteArrayOutputStream(); + YuvImage yuv = new YuvImage(nv21, ImageFormat.NV21, snapshot.getWidth(), snapshot.getHeight(), null); + if (!yuv.compressToJpeg(crop, 92, rawJpeg)) return Result.failure(snapshot.getCapturedAt(), "NV21 转图片失败"); + Bitmap bitmap = BitmapFactory.decodeByteArray(rawJpeg.toByteArray(), 0, rawJpeg.size()); + if (bitmap == null) return Result.failure(snapshot.getCapturedAt(), "图片解码失败"); + Bitmap oriented = orient(bitmap, rotation, mirror); + Bitmap scaled = scale(oriented, MAX_EDGE); + if (oriented != bitmap) bitmap.recycle(); + if (scaled != oriented) oriented.recycle(); + + byte[] encoded = compress(scaled); + scaled.recycle(); + if (encoded == null) return Result.failure(snapshot.getCapturedAt(), "JPEG 压缩失败"); + File directory = new File(contextFilesDir, "order_evidence"); + if (!directory.exists() && !directory.mkdirs()) return Result.failure(snapshot.getCapturedAt(), "证据目录不可用"); + File target = new File(directory, UUID.randomUUID().toString() + "_" + trackId + ".jpg"); + File temporary = new File(directory, "." + target.getName() + ".tmp"); + try (FileOutputStream output = new FileOutputStream(temporary)) { + output.write(encoded); + output.getFD().sync(); + } + if (!temporary.renameTo(target)) { + // Android 部分文件系统不支持跨目录原子改名,当前目录内失败时直接返回并清理。 + temporary.delete(); + return Result.failure(snapshot.getCapturedAt(), "图片文件写入失败"); + } + return Result.success(target.getAbsolutePath(), sha256(encoded), encoded.length, + scaledWidth(encoded), scaledHeight(encoded), snapshot.getCapturedAt()); + } catch (Exception error) { + return Result.failure(snapshot == null ? 0L : snapshot.getCapturedAt(), "现场图片生成失败"); + } + } + + /** + * 清理原生写图后未能写入 Floor 队列的超时文件。 + * + * @param contextFilesDir 应用私有 files 目录 + * @param referencedPaths Floor 队列仍引用的绝对路径,这些文件不受 24 小时无主清理影响 + * @param olderThanMillis 仅清理最后修改时间早于该 Unix 毫秒时间点的文件 + * @return 实际成功删除的文件数量 + */ + public static int cleanupOrphans(File contextFilesDir, Set referencedPaths, long olderThanMillis) { + File directory = new File(contextFilesDir, "order_evidence"); + File[] files = directory.listFiles(); + if (files == null || files.length == 0) return 0; + Set protectedPaths = canonicalPaths(referencedPaths); + int deleted = 0; + for (File file : files) { + if (!file.isFile() || file.lastModified() <= 0L || file.lastModified() > olderThanMillis) continue; + String name = file.getName().toLowerCase(Locale.ROOT); + boolean temporary = name.startsWith(".") && name.endsWith(".tmp"); + boolean jpeg = name.endsWith(".jpg") || name.endsWith(".jpeg"); + if (!temporary && !jpeg) continue; + if (jpeg && protectedPaths.contains(canonicalPath(file))) continue; + if (file.delete()) deleted++; + } + return deleted; + } + + /** 将 Floor 传入的路径统一为规范路径,避免路径表示差异误删仍在补传的文件。 */ + private static Set canonicalPaths(Set paths) { + Set result = new HashSet<>(); + if (paths == null) return result; + for (String path : paths) { + if (path != null && !path.trim().isEmpty()) result.add(canonicalPath(new File(path))); + } + return result; + } + + /** 返回文件规范路径;系统拒绝解析时退回绝对路径用于保守比对。 */ + private static String canonicalPath(File file) { + try { + return file.getCanonicalPath(); + } catch (Exception ignored) { + return file.getAbsolutePath(); + } + } + + /** 外扩人脸框并限制在 NV21 有效区域内,坐标调整为偶数以适配 UV 平面。 */ + private static Rect expandAndClamp(Rect source, int width, int height) { + int paddingX = Math.round(source.width() * PADDING_RATIO); + int paddingY = Math.round(source.height() * PADDING_RATIO); + int left = Math.max(0, source.left - paddingX); + int top = Math.max(0, source.top - paddingY); + int right = Math.min(width, source.right + paddingX); + int bottom = Math.min(height, source.bottom + paddingY); + left &= ~1; + top &= ~1; + right = Math.min(width, right & ~1); + bottom = Math.min(height, bottom & ~1); + return new Rect(left, top, right, bottom); + } + + /** 应用方向和前置镜像补偿。 */ + private static Bitmap orient(Bitmap source, int rotation, boolean mirror) { + int normalizedRotation = ((rotation % 360) + 360) % 360; + if (normalizedRotation == 0 && !mirror) return source; + Matrix matrix = new Matrix(); + if (normalizedRotation != 0) matrix.postRotate(normalizedRotation); + if (mirror) matrix.postScale(-1f, 1f); + return Bitmap.createBitmap(source, 0, 0, source.getWidth(), source.getHeight(), matrix, true); + } + + /** 按最长边等比缩小,避免上传大图。 */ + private static Bitmap scale(Bitmap source, int maxEdge) { + int edge = Math.max(source.getWidth(), source.getHeight()); + if (edge <= maxEdge) return source; + float ratio = maxEdge / (float) edge; + return Bitmap.createScaledBitmap(source, Math.round(source.getWidth() * ratio), + Math.round(source.getHeight() * ratio), true); + } + + /** 逐级压缩到约 100KB;缩放后仍过大时保留最低质量结果供服务端校验。 */ + private static byte[] compress(Bitmap bitmap) { + for (int quality : new int[]{88, 78, 68, 58, 48, 40}) { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.JPEG, quality, output); + if (output.size() <= TARGET_BYTES || quality == 40) return output.toByteArray(); + } + return null; + } + + /** 读取压缩图片宽度;失败时返回 0,避免再次持有 Bitmap。 */ + private static int scaledWidth(byte[] jpeg) { + Bitmap bitmap = BitmapFactory.decodeByteArray(jpeg, 0, jpeg.length); + if (bitmap == null) return 0; + int width = bitmap.getWidth(); + bitmap.recycle(); + return width; + } + + /** 读取压缩图片高度;失败时返回 0,避免再次持有 Bitmap。 */ + private static int scaledHeight(byte[] jpeg) { + Bitmap bitmap = BitmapFactory.decodeByteArray(jpeg, 0, jpeg.length); + if (bitmap == null) return 0; + int height = bitmap.getHeight(); + bitmap.recycle(); + return height; + } + + /** 计算现场 JPEG 的 SHA-256。 */ + private static String sha256(byte[] content) throws NoSuchAlgorithmException { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(content); + StringBuilder result = new StringBuilder(digest.length * 2); + for (byte value : digest) result.append(String.format(Locale.ROOT, "%02x", value)); + return result.toString(); + } + + /** 单张现场图写入结果。 */ + public static final class Result { + /** 私有文件路径,失败时为空。 */ + public final String filePath; + /** 文件 SHA-256,失败时为空。 */ + public final String hash; + /** 文件大小,单位字节。 */ + public final int size; + /** 图片宽度,单位像素。 */ + public final int width; + /** 图片高度,单位像素。 */ + public final int height; + /** 原始帧采集时间,单位 Unix 毫秒。 */ + public final long capturedAt; + /** 安全失败摘要,成功时为空。 */ + public final String error; + + /** 创建成功或失败结果;成功时文件字段完整,失败时仅保留采集时间和安全摘要。 */ + private Result(String filePath, String hash, int size, int width, int height, long capturedAt, String error) { + this.filePath = filePath; + this.hash = hash; + this.size = size; + this.width = width; + this.height = height; + this.capturedAt = capturedAt; + this.error = error; + } + + /** 创建成功结果。 */ + public static Result success(String filePath, String hash, int size, int width, int height, long capturedAt) { + return new Result(filePath, hash, size, width, height, capturedAt, null); + } + + /** 创建失败结果。 */ + public static Result failure(long capturedAt, String error) { + return new Result(null, null, 0, 0, 0, capturedAt, error); + } + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/session/FacePreviewRenderer.java b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FacePreviewRenderer.java new file mode 100644 index 0000000..af787a7 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FacePreviewRenderer.java @@ -0,0 +1,413 @@ +package io.flutter.plugins.facediscern.session; + +import android.graphics.SurfaceTexture; +import android.opengl.EGL14; +import android.opengl.EGLConfig; +import android.opengl.EGLContext; +import android.opengl.EGLDisplay; +import android.opengl.EGLExt; +import android.opengl.EGLSurface; +import android.opengl.GLES11Ext; +import android.opengl.GLES20; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.util.Log; +import android.view.Surface; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import io.flutter.view.TextureRegistry; + +/** + * Camera1 只绑定一个 OES 输入纹理,渲染线程再把同一帧复制到多个 Flutter Texture。 + * 这样主、副屏不会各自打开摄像头,也不需要把 NV21 转成 Bitmap 造成大块内存复制。 + */ +final class FacePreviewRenderer implements SurfaceTexture.OnFrameAvailableListener { + private static final String TAG = "FacePreviewRenderer"; + private static final AtomicLong NEXT_OUTPUT_ID = new AtomicLong(1L); + + private static final float[] VERTICES = { + // SurfaceTexture#getTransformMatrix 已包含生产者到 GL 纹理坐标的 Y 轴补偿, + // 这里必须使用标准 UV;再次手工翻转会让主副屏画面上下颠倒。 + -1f, -1f, 0f, 0f, + 1f, -1f, 1f, 0f, + -1f, 1f, 0f, 1f, + 1f, 1f, 1f, 1f, + }; + + private static final String VERTEX_SHADER = + "attribute vec2 aPosition;\n" + + "attribute vec2 aTextureCoord;\n" + + "uniform mat4 uTextureMatrix;\n" + + "varying vec2 vTextureCoord;\n" + + "void main() {\n" + + " gl_Position = vec4(aPosition, 0.0, 1.0);\n" + + " vTextureCoord = (uTextureMatrix * vec4(aTextureCoord, 0.0, 1.0)).xy;\n" + + "}\n"; + + private static final String FRAGMENT_SHADER = + "#extension GL_OES_EGL_image_external : require\n" + + "precision mediump float;\n" + + "varying vec2 vTextureCoord;\n" + + "uniform samplerExternalOES uTexture;\n" + + "void main() {\n" + + " gl_FragColor = texture2D(uTexture, vTextureCoord);\n" + + "}\n"; + + private final HandlerThread renderThread = new HandlerThread("face-preview-render"); + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private Handler renderHandler; + private final Object lock = new Object(); + private final Map outputs = new HashMap<>(); + + /** + * 当前主、副 Flutter Engine 实际持有的预览输出数量。 + * Camera 回调线程只读取该原子值,不跨线程直接访问渲染线程拥有的 outputs。 + */ + private final AtomicInteger activeOutputCount = new AtomicInteger(); + + private final float[] textureMatrix = new float[16]; + + private EGLDisplay eglDisplay = EGL14.EGL_NO_DISPLAY; + private EGLContext eglContext = EGL14.EGL_NO_CONTEXT; + private EGLSurface pbufferSurface = EGL14.EGL_NO_SURFACE; + private EGLConfig eglConfig; + private SurfaceTexture cameraTexture; + private int oesTextureId; + private int program; + private int positionHandle; + private int textureCoordHandle; + private int textureMatrixHandle; + private FloatBuffer vertexBuffer; + private int previewWidth = 1280; + private int previewHeight = 720; + private boolean initialized; + private boolean released; + private boolean firstFrameLogged; + private volatile boolean frozen; + + FacePreviewRenderer() { + renderThread.start(); + renderHandler = new Handler(renderThread.getLooper()); + } + + SurfaceTexture getCameraTexture() throws Exception { + runBlocking(() -> { + ensureInitialized(); + cameraTexture.setDefaultBufferSize(previewWidth, previewHeight); + }); + return cameraTexture; + } + + void setPreviewSize(int width, int height) { + if (width <= 0 || height <= 0) return; + previewWidth = width; + previewHeight = height; + Handler handler = renderHandler; + if (handler != null) { + handler.post(() -> { + if (cameraTexture != null) cameraTexture.setDefaultBufferSize(width, height); + }); + } + } + + long addOutput(TextureRegistry.SurfaceTextureEntry entry) throws Exception { + // Texture ID 只在各自 Flutter Engine 内唯一,主屏和副屏的首个 ID 通常都会是 0。 + // 共享渲染器必须另分配进程级输出编号,否则副屏会被误判为主屏的重复 Texture。 + // 编号必须跨 Renderer 重建保持唯一,避免旧页面的迟到 release 误删新会话输出。 + long outputId = NEXT_OUTPUT_ID.getAndIncrement(); + runBlocking(() -> { + ensureInitialized(); + SurfaceTexture surfaceTexture = entry.surfaceTexture(); + surfaceTexture.setDefaultBufferSize(previewWidth, previewHeight); + Surface surface = new Surface(surfaceTexture); + int[] surfaceAttributes = {EGL14.EGL_NONE}; + EGLSurface eglSurface = EGL14.eglCreateWindowSurface(eglDisplay, eglConfig, surface, surfaceAttributes, 0); + if (eglSurface == null || eglSurface == EGL14.EGL_NO_SURFACE) { + surface.release(); + throw new IllegalStateException("无法创建 Flutter 人脸预览 EGLSurface"); + } + outputs.put(outputId, new OutputTarget(entry, surface, eglSurface)); + activeOutputCount.set(outputs.size()); + Log.i(TAG, "预览输出已添加 outputId=" + outputId + ";flutterTextureId=" + entry.id() + ";outputCount=" + outputs.size()); + }); + return outputId; + } + + void removeOutput(long outputId) { + Handler handler = renderHandler; + if (handler == null) return; + handler.post(() -> releaseOutput(outputId)); + } + + /** + * 判断是否仍有界面需要实时人脸框。 + * + * @return 主屏或副屏至少持有一个有效 Texture 输出时返回 true + */ + boolean hasActiveOutputs() { + return activeOutputCount.get() > 0; + } + + /** + * 成功后继续保留 Camera 热资源,只暂停向主副屏 Texture 提交新帧。 + * 这样界面会停在通过阈值的最后一帧,下一位顾客开始时再恢复,不需要重新开 Camera。 + */ + void setFrozen(boolean frozen) { + boolean wasFrozen = this.frozen; + this.frozen = frozen; + Log.i(TAG, frozen ? "人脸预览已冻结" : "人脸预览已恢复"); + if (wasFrozen && !frozen) { + Handler handler = renderHandler; + if (handler != null) { + // 冻结期间不调用 updateTexImage 会让 SurfaceTexture 积压一帧, + // Camera 生产端随后可能停在缓冲队列上,不再产生新的帧回调。 + // 新会话解冻时主动消费该帧,释放生产端后主、副屏才能继续收到实时画面。 + handler.post(() -> { + if (!this.frozen) { + firstFrameLogged = false; + renderFrame(); + } + }); + } + } + } + + @Override + public void onFrameAvailable(SurfaceTexture surfaceTexture) { + if (frozen) return; + Handler handler = renderHandler; + if (handler != null) handler.post(this::renderFrame); + } + + void release() { + Handler handler = renderHandler; + if (handler == null) return; + try { + runBlocking(this::releaseInternal); + } catch (Exception error) { + Log.w(TAG, "释放预览渲染器失败", error); + } + renderHandler = null; + renderThread.quitSafely(); + } + + private void ensureInitialized() { + if (initialized) return; + if (released) throw new IllegalStateException("预览渲染器已释放"); + eglDisplay = EGL14.eglGetDisplay(EGL14.EGL_DEFAULT_DISPLAY); + int[] version = new int[2]; + if (!EGL14.eglInitialize(eglDisplay, version, 0, version, 1)) { + throw new IllegalStateException("EGL 初始化失败"); + } + int[] configAttributes = { + EGL14.EGL_RENDERABLE_TYPE, EGL14.EGL_OPENGL_ES2_BIT, + EGL14.EGL_RED_SIZE, 8, + EGL14.EGL_GREEN_SIZE, 8, + EGL14.EGL_BLUE_SIZE, 8, + EGL14.EGL_ALPHA_SIZE, 8, + EGL14.EGL_SURFACE_TYPE, EGL14.EGL_WINDOW_BIT | EGL14.EGL_PBUFFER_BIT, + EGL14.EGL_NONE, + }; + EGLConfig[] configs = new EGLConfig[1]; + int[] configCount = new int[1]; + EGL14.eglChooseConfig(eglDisplay, configAttributes, 0, configs, 0, 1, configCount, 0); + if (configCount[0] == 0) throw new IllegalStateException("未找到可用 EGLConfig"); + eglConfig = configs[0]; + int[] contextAttributes = {EGL14.EGL_CONTEXT_CLIENT_VERSION, 2, EGL14.EGL_NONE}; + eglContext = EGL14.eglCreateContext(eglDisplay, eglConfig, EGL14.EGL_NO_CONTEXT, contextAttributes, 0); + int[] pbufferAttributes = {EGL14.EGL_WIDTH, 1, EGL14.EGL_HEIGHT, 1, EGL14.EGL_NONE}; + pbufferSurface = EGL14.eglCreatePbufferSurface(eglDisplay, eglConfig, pbufferAttributes, 0); + makeCurrent(pbufferSurface); + + int[] textureIds = new int[1]; + GLES20.glGenTextures(1, textureIds, 0); + oesTextureId = textureIds[0]; + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, oesTextureId); + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR); + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE); + GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE); + cameraTexture = new SurfaceTexture(oesTextureId); + cameraTexture.setDefaultBufferSize(previewWidth, previewHeight); + cameraTexture.setOnFrameAvailableListener(this, renderHandler); + + program = createProgram(VERTEX_SHADER, FRAGMENT_SHADER); + positionHandle = GLES20.glGetAttribLocation(program, "aPosition"); + textureCoordHandle = GLES20.glGetAttribLocation(program, "aTextureCoord"); + textureMatrixHandle = GLES20.glGetUniformLocation(program, "uTextureMatrix"); + vertexBuffer = ByteBuffer.allocateDirect(VERTICES.length * 4) + .order(ByteOrder.nativeOrder()).asFloatBuffer(); + vertexBuffer.put(VERTICES).position(0); + firstFrameLogged = false; + initialized = true; + } + + private void renderFrame() { + if (!initialized || released || frozen || cameraTexture == null) return; + makeCurrent(pbufferSurface); + try { + cameraTexture.updateTexImage(); + cameraTexture.getTransformMatrix(textureMatrix); + } catch (RuntimeException error) { + Log.w(TAG, "更新相机纹理失败", error); + return; + } + if (!firstFrameLogged) { + firstFrameLogged = true; + Log.i(TAG, "预览首帧已到达 outputCount=" + outputs.size() + ";size=" + previewWidth + "x" + previewHeight); + } + for (OutputTarget output : outputs.values().toArray(new OutputTarget[0])) { + if (!makeCurrent(output.eglSurface)) continue; + GLES20.glViewport(0, 0, previewWidth, previewHeight); + GLES20.glClearColor(0f, 0f, 0f, 1f); + GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT); + GLES20.glUseProgram(program); + GLES20.glActiveTexture(GLES20.GL_TEXTURE0); + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, oesTextureId); + + vertexBuffer.position(0); + GLES20.glEnableVertexAttribArray(positionHandle); + GLES20.glVertexAttribPointer(positionHandle, 2, GLES20.GL_FLOAT, false, 16, vertexBuffer); + vertexBuffer.position(2); + GLES20.glEnableVertexAttribArray(textureCoordHandle); + GLES20.glVertexAttribPointer(textureCoordHandle, 2, GLES20.GL_FLOAT, false, 16, vertexBuffer); + GLES20.glUniformMatrix4fv(textureMatrixHandle, 1, false, textureMatrix, 0); + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4); + EGLExt.eglPresentationTimeANDROID(eglDisplay, output.eglSurface, cameraTexture.getTimestamp()); + if (!EGL14.eglSwapBuffers(eglDisplay, output.eglSurface)) { + Log.w(TAG, "Flutter 人脸预览交换缓冲区失败 textureId=" + output.entry.id()); + } + } + makeCurrent(pbufferSurface); + } + + private boolean makeCurrent(EGLSurface surface) { + return EGL14.eglMakeCurrent(eglDisplay, surface, surface, eglContext); + } + + private int createProgram(String vertexSource, String fragmentSource) { + int vertex = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource); + int fragment = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource); + int result = GLES20.glCreateProgram(); + GLES20.glAttachShader(result, vertex); + GLES20.glAttachShader(result, fragment); + GLES20.glLinkProgram(result); + int[] linkStatus = new int[1]; + GLES20.glGetProgramiv(result, GLES20.GL_LINK_STATUS, linkStatus, 0); + GLES20.glDeleteShader(vertex); + GLES20.glDeleteShader(fragment); + if (linkStatus[0] == 0) { + String info = GLES20.glGetProgramInfoLog(result); + GLES20.glDeleteProgram(result); + throw new IllegalStateException("GL 程序链接失败:" + info); + } + return result; + } + + private int compileShader(int type, String source) { + int shader = GLES20.glCreateShader(type); + GLES20.glShaderSource(shader, source); + GLES20.glCompileShader(shader); + int[] compileStatus = new int[1]; + GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0); + if (compileStatus[0] == 0) { + String info = GLES20.glGetShaderInfoLog(shader); + GLES20.glDeleteShader(shader); + throw new IllegalStateException("GL Shader 编译失败:" + info); + } + return shader; + } + + private void releaseOutput(long outputId) { + OutputTarget output = outputs.remove(outputId); + if (output == null) return; + activeOutputCount.set(outputs.size()); + if (eglDisplay != EGL14.EGL_NO_DISPLAY && output.eglSurface != EGL14.EGL_NO_SURFACE) { + EGL14.eglDestroySurface(eglDisplay, output.eglSurface); + } + output.surface.release(); + // EGLSurface 与 Android Surface 属于渲染线程;Flutter Texture 注册只能在主线程解除。 + long releasedTextureId = output.entry.id(); + mainHandler.post(() -> { + try { + output.entry.release(); + Log.i(TAG, "Flutter 预览 Texture 已释放 outputId=" + outputId + ";textureId=" + releasedTextureId); + } catch (RuntimeException error) { + Log.w(TAG, "释放 Flutter 预览 Texture 失败 outputId=" + outputId + ";textureId=" + releasedTextureId, error); + } + }); + } + + private void releaseInternal() { + if (released) return; + released = true; + for (Long textureId : outputs.keySet().toArray(new Long[0])) releaseOutput(textureId); + activeOutputCount.set(0); + if (cameraTexture != null) { + cameraTexture.setOnFrameAvailableListener(null); + cameraTexture.release(); + cameraTexture = null; + } + if (program != 0) GLES20.glDeleteProgram(program); + if (oesTextureId != 0) GLES20.glDeleteTextures(1, new int[]{oesTextureId}, 0); + if (eglDisplay != EGL14.EGL_NO_DISPLAY) { + makeCurrent(pbufferSurface); + if (pbufferSurface != EGL14.EGL_NO_SURFACE) EGL14.eglDestroySurface(eglDisplay, pbufferSurface); + if (eglContext != EGL14.EGL_NO_CONTEXT) EGL14.eglDestroyContext(eglDisplay, eglContext); + EGL14.eglTerminate(eglDisplay); + } + initialized = false; + } + + private void runBlocking(ThrowingRunnable runnable) throws Exception { + Handler handler = renderHandler; + if (handler == null) throw new IllegalStateException("预览渲染线程不可用"); + if (Thread.currentThread() == handler.getLooper().getThread()) { + runnable.run(); + return; + } + final Object waitLock = new Object(); + final Exception[] failure = new Exception[1]; + final boolean[] completed = {false}; + handler.post(() -> { + try { + runnable.run(); + } catch (Exception error) { + failure[0] = error; + } finally { + synchronized (waitLock) { + completed[0] = true; + waitLock.notifyAll(); + } + } + }); + synchronized (waitLock) { + while (!completed[0]) waitLock.wait(); + } + if (failure[0] != null) throw failure[0]; + } + + private interface ThrowingRunnable { + void run() throws Exception; + } + + private static final class OutputTarget { + final TextureRegistry.SurfaceTextureEntry entry; + final Surface surface; + final EGLSurface eglSurface; + + OutputTarget(TextureRegistry.SurfaceTextureEntry entry, Surface surface, EGLSurface eglSurface) { + this.entry = entry; + this.surface = surface; + this.eglSurface = eglSurface; + } + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java new file mode 100644 index 0000000..9b9e1be --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/session/FaceRecognitionSessionManager.java @@ -0,0 +1,1116 @@ +package io.flutter.plugins.facediscern.session; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.SurfaceTexture; +import android.hardware.Camera; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.util.Log; +import android.view.Surface; +import android.view.WindowManager; + +import androidx.core.content.ContextCompat; + +import com.arcsoft.face.LivenessInfo; +import com.google.gson.Gson; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; + +import io.flutter.plugins.facediscern.tool.face.model.FacePreviewInfo; +import io.flutter.plugins.facediscern.tool.face.model.RecognitionFrameSnapshot; +import io.flutter.plugins.facediscern.tool.face.constants.RequestFeatureStatus; +import io.flutter.plugins.facediscern.tool.face.constants.RequestLivenessStatus; +import io.flutter.plugins.facediscern.tool.camera.config.PreviewConfig; +import io.flutter.plugins.facediscern.ui.viewmodel.FaceDiscernViewModel; +import io.flutter.view.TextureRegistry; + +/** + * 进程级人脸会话:一套 Camera1 和虹软处理链服务主、副两个 Flutter Engine。 + * 会话结束后只停止识别,摄像头与引擎保持 20 分钟热状态,适配连续排队支付。 + */ +public final class FaceRecognitionSessionManager implements Camera.PreviewCallback { + private static final String TAG = "FaceSessionManager"; + private static final long WARM_TIMEOUT_MS = 20L * 60L * 1000L; + private static final int TARGET_WIDTH = 1280; + private static final int TARGET_HEIGHT = 720; + + /** 人脸框事件的最小发送间隔,避免主副屏因高频 Map 消息持续重建。 */ + private static final long FACE_BOX_THROTTLE_MS = 100L; + + /** + * 虹软在运动模糊或角度变化时允许的短暂漏检时间。 + * 每张脸分别保留最近坐标,连续漏检超时后才确认该人员已经离开。 + */ + private static final long FACE_BOX_MISS_GRACE_MS = 600L; + + /** 结果展示期仅做人脸检测的最小帧间隔,限制热预览的 CPU 消耗。 */ + private static final long PREVIEW_ONLY_DETECTION_INTERVAL_MS = 100L; + + public enum State { + COLD("cold"), + INITIALIZING("initializing"), + DETECTING("detecting"), + SUCCESS("success"), + FAILED("failed"), + CANCELLED("cancelled"), + WARM_IDLE("warmIdle"); + + final String code; + + State(String code) { + this.code = code; + } + } + + public interface Listener { + void onSessionEvent(Map event); + + void onRecognitionResult(String json); + } + + private static volatile FaceRecognitionSessionManager instance; + + public static FaceRecognitionSessionManager getInstance(Context context) { + if (instance == null) { + synchronized (FaceRecognitionSessionManager.class) { + if (instance == null) instance = new FaceRecognitionSessionManager(context.getApplicationContext()); + } + } + return instance; + } + + private final Context context; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final ExecutorService recognitionExecutor = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "face-recognition-frame"); + thread.setPriority(Thread.NORM_PRIORITY + 1); + return thread; + }); + + /** Camera 回调是否已有唯一帧任务在执行或排队,由成功取得 CAS 的任务负责释放。 */ + private final AtomicBoolean frameInFlight = new AtomicBoolean(false); + private final CopyOnWriteArrayList listeners = new CopyOnWriteArrayList<>(); + private final Object sessionLock = new Object(); + private final Object processorLock = new Object(); + + private FacePreviewRenderer previewRenderer; + private Camera camera; + private FaceDiscernViewModel processor; + private Camera.Size previewSize; + private int cameraId = -1; + private int displayOrientation; + private boolean mirror; + private boolean warmResourcesReady; + private State state = State.COLD; + private String activeSessionId; + private String activeScene; + private Listener activeOwner; + private long revision; + + /** 进程生命周期内单调递增的事件序号,用于主副屏丢弃跨业务会话迟到的状态。 */ + private long eventSequence; + + private long sessionGeneration; + private long lastFaceBoxEmitAt; + + /** 结果展示期最近一次提交“仅检测”任务的时间戳,单位为毫秒。 */ + private long lastPreviewOnlyDetectionAt; + + /** 每个虹软跟踪编号最近一次真实检测到的时间戳,使用单调时钟且单位为毫秒。 */ + private Map lastFaceBoxSeenAt = new HashMap<>(); + + /** 当前向 Flutter 展示的人脸框,包含真实检测框和仍在漏检宽限内的单人缓存。 */ + private List> lastFaceBoxes = new ArrayList<>(); + private boolean recognitionConfigurationDirty; + private Handler warmReleaseHandler = new Handler(Looper.getMainLooper()); + private final Runnable warmReleaseTask = this::releaseWarmResources; + + private FaceRecognitionSessionManager(Context context) { + this.context = context; + } + + public void addListener(Listener listener) { + if (listener != null) listeners.addIfAbsent(listener); + } + + public void removeListener(Listener listener) { + listeners.remove(listener); + } + + /** + * 仅枚举 Android Camera 设备,不打开相机、不申请权限,也不改变当前人脸会话状态。 + * + * @return 至少存在一个系统可枚举摄像头时返回 true;枚举异常按未检测到处理。 + */ + @SuppressWarnings("deprecation") + public boolean hasCamera() { + try { + return Camera.getNumberOfCameras() > 0; + } catch (RuntimeException error) { + Log.w(TAG, "枚举摄像头失败", error); + return false; + } + } + + /** + * 清理超过保留截止时间且没有被 Flutter 本地队列引用的现场图。 + * + * @param referencedPaths Floor 任务仍持有的现场图绝对路径 + * @param olderThanMillis 无主文件清理截止时间,单位 Unix 毫秒 + * @return 成功删除数量 + */ + public int cleanupOrderEvidenceFiles(Set referencedPaths, long olderThanMillis) { + return FaceEvidenceImageWriter.cleanupOrphans(context.getFilesDir(), referencedPaths, olderThanMillis); + } + + public Map createPreviewTexture(TextureRegistry textureRegistry) throws Exception { + TextureRegistry.SurfaceTextureEntry entry = textureRegistry.createSurfaceTexture(); + FacePreviewRenderer renderer; + synchronized (sessionLock) { + ensurePreviewRenderer(); + renderer = previewRenderer; + } + try { + long outputId = renderer.addOutput(entry); + synchronized (sessionLock) { + Map result = new HashMap<>(); + // textureId 返回给当前 Flutter Engine;previewOutputId 仅供原生插件端点释放共享输出。 + result.put("textureId", entry.id()); + result.put("previewOutputId", outputId); + result.put("width", previewSize == null ? TARGET_WIDTH : previewSize.width); + result.put("height", previewSize == null ? TARGET_HEIGHT : previewSize.height); + result.put("rotation", displayOrientation); + result.put("mirror", mirror); + return result; + } + } catch (Exception error) { + entry.release(); + throw error; + } + } + + public void releasePreviewTexture(long outputId) { + FacePreviewRenderer renderer; + synchronized (sessionLock) { + renderer = previewRenderer; + } + if (renderer != null) renderer.removeOutput(outputId); + } + + public Map startRecognition(String sessionId, String scene, Listener owner) throws Exception { + if (sessionId == null || sessionId.trim().isEmpty()) throw new IllegalArgumentException("缺少人脸会话编号"); + Log.i(TAG, "发起原生人脸会话 sessionId=" + sessionId + ";scene=" + scene); + boolean reloadConfiguration; + synchronized (sessionLock) { + if (activeSessionId != null && !Objects.equals(activeSessionId, sessionId)) { + throw new SessionBusyException(activeSessionId); + } + if (Objects.equals(activeSessionId, sessionId)) return snapshot(); + cancelWarmRelease(); + activeSessionId = sessionId; + activeScene = scene == null || scene.trim().isEmpty() ? "cashier" : scene; + activeOwner = owner; + revision = 0; + state = State.INITIALIZING; + reloadConfiguration = recognitionConfigurationDirty; + emitState("正在启动摄像头", recentFaceBoxesLocked(SystemClock.elapsedRealtime()), null); + } + try { + // 保存配置时不打断上一位顾客;下一次 Session 启动前再重建引擎并读取新值。 + if (reloadConfiguration) releaseResources(false, false); + ensureWarmResources(); + resetProcessorForSession(sessionId); + } catch (Exception error) { + Log.e(TAG, "人脸会话初始化失败 sessionId=" + sessionId, error); + synchronized (sessionLock) { + if (Objects.equals(activeSessionId, sessionId)) { + state = State.FAILED; + emitState(error.getMessage() == null ? "人脸识别初始化失败" : error.getMessage(), null, "INITIALIZATION_FAILED"); + } + } + // 初始化失败仍保留业务 Session,用户点击重试时可以在同一 Session 内重新冷启动。 + releaseResources(false, false); + throw error; + } + synchronized (sessionLock) { + if (!Objects.equals(activeSessionId, sessionId)) { + throw new IllegalStateException("当前人脸会话已结束"); + } + state = State.DETECTING; + emitState("请正对摄像头", recentFaceBoxesLocked(SystemClock.elapsedRealtime()), null); + Log.i(TAG, "人脸会话进入识别状态 sessionId=" + sessionId); + return snapshot(); + } + } + + public Map retryRecognition(String sessionId) throws Exception { + synchronized (sessionLock) { + ensureOwner(sessionId); + state = State.INITIALIZING; + emitState( + warmResourcesReady ? "正在重置识别状态" : "正在重新启动摄像头", + recentFaceBoxesLocked(SystemClock.elapsedRealtime()), + null); + } + try { + ensureWarmResources(); + resetProcessorForSession(sessionId); + } catch (Exception error) { + synchronized (sessionLock) { + if (Objects.equals(activeSessionId, sessionId)) { + state = State.FAILED; + emitState(error.getMessage() == null ? "重新启动人脸识别失败" : error.getMessage(), null, "INITIALIZATION_FAILED"); + } + } + releaseResources(false, false); + throw error; + } + synchronized (sessionLock) { + ensureOwner(sessionId); + state = State.DETECTING; + emitState("请调整位置后重新识别", recentFaceBoxesLocked(SystemClock.elapsedRealtime()), null); + return snapshot(); + } + } + + public Map cancelRecognition(String sessionId, String message) { + FaceDiscernViewModel processorToStop; + synchronized (sessionLock) { + if (activeSessionId == null) return snapshot(); + if (sessionId != null && !sessionId.isEmpty() && !Objects.equals(activeSessionId, sessionId)) return snapshot(); + state = State.CANCELLED; + clearFaceBoxCacheLocked(); + emitState(message == null ? "已取消人脸识别" : message, null, null); + processorToStop = detachActiveSession(); + } + stopProcessor(processorToStop); + if (isRecognitionConfigurationDirty()) { + releaseResources(true, true); + } else { + emitResourceState("摄像头热保持中"); + } + synchronized (sessionLock) { + return snapshot(); + } + } + + /** + * 结束当前业务识别但保留热 Camera,并恢复 Texture 的实时帧提交。 + * + *

成功回调会冻结最后一帧供独立弹框确认;调用方结束 Session 后必须解冻, + * 持续查询页面才能在十秒菜单展示期继续显示实时画面,同时又不处理下一位人脸。

+ */ + public Map finishRecognition(String sessionId) { + FaceDiscernViewModel processorToStop; + FacePreviewRenderer renderer; + synchronized (sessionLock) { + if (activeSessionId == null) return snapshot(); + if (sessionId != null && !sessionId.isEmpty() && !Objects.equals(activeSessionId, sessionId)) return snapshot(); + processorToStop = detachActiveSession(); + renderer = previewRenderer; + } + stopProcessor(processorToStop); + if (renderer != null) renderer.setFrozen(false); + if (isRecognitionConfigurationDirty()) { + releaseResources(true, true); + } else { + emitResourceState("摄像头热保持中"); + } + synchronized (sessionLock) { + return snapshot(); + } + } + + public Map suspendRecognition(String reason) { + synchronized (sessionLock) { + if (activeSessionId != null) { + state = State.CANCELLED; + emitState(reason == null ? "应用已暂停,人脸识别已结束" : reason, null, "SESSION_SUSPENDED"); + } + } + releaseResources(true, true); + synchronized (sessionLock) { + return snapshot(); + } + } + + /** + * 返回当前原生资源和业务会话快照;检测、成功及热预览状态会附带可继续展示的人脸框。 + * + * @return 可供 MethodChannel 或新 EventChannel 订阅者立即渲染的完整状态 + */ + public Map getState() { + synchronized (sessionLock) { + Map result = snapshot(); + result.put("faceBoxes", currentStateFaceBoxesLocked(SystemClock.elapsedRealtime())); + return result; + } + } + + /** + * 空闲时立即释放热资源,让下次识别读取新配置;活动识别则延迟到当前顾客结束,避免中断支付。 + */ + public void onRecognitionConfigurationChanged() { + boolean releaseNow; + synchronized (sessionLock) { + recognitionConfigurationDirty = true; + releaseNow = activeSessionId == null; + } + // 设置页没有预览纹理占用,空闲时立即释放可避免下一次创建纹理后再重建 Renderer。 + if (releaseNow) releaseResources(true, true); + } + + public void releaseForLowMemory() { + synchronized (sessionLock) { + if (activeSessionId != null) { + state = State.FAILED; + emitState("系统资源不足,人脸识别已结束", null, "LOW_MEMORY"); + } + } + releaseResources(true, true); + } + + private void ensureWarmResources() throws Exception { + FacePreviewRenderer renderer; + synchronized (sessionLock) { + if (warmResourcesReady && camera != null && processor != null) { + Log.i(TAG, "引擎初始化跳过,复用热资源"); + return; + } + ensurePreviewRenderer(); + renderer = previewRenderer; + } + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { + throw new SecurityException("未获得相机权限"); + } + long engineStartAt = System.currentTimeMillis(); + Log.i(TAG, "虹软引擎初始化开始"); + FaceDiscernViewModel newProcessor = new FaceDiscernViewModel(); + newProcessor.init(context); + newProcessor.setRecognitionListener(this::onRecognized); + if (!newProcessor.isEngineReady()) { + String initSummary = newProcessor.getEngineInitSummary(); + Log.e(TAG, "虹软引擎初始化返回非成功码 " + initSummary); + newProcessor.destroy(); + throw new IllegalStateException("虹软识别引擎初始化失败(" + initSummary + ")"); + } + Log.i(TAG, "虹软引擎初始化完成 cost=" + (System.currentTimeMillis() - engineStartAt) + "ms;" + newProcessor.getEngineInitSummary()); + synchronized (sessionLock) { + processor = newProcessor; + previewRenderer = renderer; + } + try { + openCamera(); + synchronized (sessionLock) { + warmResourcesReady = true; + } + } catch (Exception error) { + releaseResources(false, false); + throw error; + } + } + + private void ensurePreviewRenderer() { + if (previewRenderer == null) previewRenderer = new FacePreviewRenderer(); + } + + @SuppressWarnings("deprecation") + private void openCamera() throws Exception { + cameraId = findRgbCameraId(); + Camera.CameraInfo info = new Camera.CameraInfo(); + Camera.getCameraInfo(cameraId, info); + mirror = info.facing == Camera.CameraInfo.CAMERA_FACING_FRONT; + // SurfaceTexture 保留相机原始方向,Flutter 端需使用与旧版 CameraHelper 相同的 + // 传感器角度 + 当前屏幕旋转补偿;反向横屏设备否则会整体上下颠倒。 + int displayDegrees = getDisplayRotationDegrees(); + if (mirror) { + displayOrientation = (360 - ((info.orientation + displayDegrees) % 360)) % 360; + } else { + displayOrientation = (info.orientation - displayDegrees + 360) % 360; + } + long cameraOpenAt = System.currentTimeMillis(); + Log.i(TAG, "Camera.open开始 cameraId=" + cameraId + ";sensorOrientation=" + info.orientation + + ";displayDegrees=" + displayDegrees + ";previewRotation=" + displayOrientation); + camera = Camera.open(cameraId); + Log.i(TAG, "Camera.open成功 cameraId=" + cameraId + ";cost=" + (System.currentTimeMillis() - cameraOpenAt) + "ms"); + Camera.Parameters parameters = camera.getParameters(); + previewSize = choosePreviewSize(parameters.getSupportedPreviewSizes()); + parameters.setPreviewSize(previewSize.width, previewSize.height); + parameters.setPreviewFormat(android.graphics.ImageFormat.NV21); + List focusModes = parameters.getSupportedFocusModes(); + if (focusModes != null) { + if (focusModes.contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO)) { + parameters.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO); + } else if (focusModes.contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) { + parameters.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE); + } + } + camera.setParameters(parameters); + // OES 输出保持传感器原始方向,主、副两个 Flutter Texture 统一按 rotation 字段旋转; + // 若此处同时 setDisplayOrientation,会在部分设备上和 Flutter RotatedBox 叠加成双重旋转。 + camera.setDisplayOrientation(0); + Camera openedCamera = camera; + camera.setErrorCallback((error, callbackCamera) -> mainHandler.post(() -> { + synchronized (sessionLock) { + if (camera != callbackCamera || camera != openedCamera) return; + } + handleFatalFailure("摄像头连接异常,请检查设备后重试", "CAMERA_ERROR_" + error, 0L); + })); + previewRenderer.setPreviewSize(previewSize.width, previewSize.height); + SurfaceTexture inputTexture = previewRenderer.getCameraTexture(); + camera.setPreviewTexture(inputTexture); + int bufferSize = previewSize.width * previewSize.height * android.graphics.ImageFormat.getBitsPerPixel(android.graphics.ImageFormat.NV21) / 8; + for (int i = 0; i < 3; i++) camera.addCallbackBuffer(new byte[bufferSize]); + camera.setPreviewCallbackWithBuffer(this); + camera.startPreview(); + Log.i(TAG, "Camera.startPreview完成 size=" + previewSize.width + "x" + previewSize.height); + processor.onRgbCameraOpened(camera); + // 虹软需要相机坐标转换器,新的 UI 使用归一化框,因此目标尺寸直接使用预览尺寸即可。 + // FaceRectTransformer 的历史参数名虽为 cameraId,内部实际按 FRONT/BACK facing 判断方向; + // 本机 Camera 1 才是 BACK/RGB,直接传数字 ID 会被误判为 FRONT 并造成识别区域镜像。 + processor.setRgbFaceRectTransformer(new io.flutter.plugins.facediscern.tool.face.help.FaceRectTransformer( + previewSize.width, previewSize.height, previewSize.width, previewSize.height, + displayOrientation, info.facing, mirror, true, true)); + } + + /** + * 接收 Camera1 的 NV21 回调,并按当前会话状态提交识别或仅检测任务。 + * 未被选中的帧会立即归还 Camera 缓冲区,避免高频回调积压。 + * + * @param data Camera1 复用的 NV21 帧缓冲区 + * @param callbackCamera 产生该帧的 Camera 实例 + */ + @Override + @SuppressWarnings("deprecation") + public void onPreviewFrame(byte[] data, Camera callbackCamera) { + if (data == null || callbackCamera == null) return; + boolean recognize = false; + boolean previewOnly = false; + FaceDiscernViewModel frameProcessor = null; + Camera.Size framePreviewSize = null; + long frameGeneration = 0L; + long now = SystemClock.elapsedRealtime(); + synchronized (sessionLock) { + // Camera、处理器和业务代次必须在入队时固定,禁止上一轮已经回调的 NV21 帧跨轮次进入新会话。 + if (callbackCamera == camera && processor != null && previewSize != null) { + recognize = state == State.DETECTING && activeSessionId != null; + previewOnly = !recognize + && state == State.WARM_IDLE + && activeSessionId == null + && warmResourcesReady + && previewRenderer != null + && previewRenderer.hasActiveOutputs() + && now - lastPreviewOnlyDetectionAt >= PREVIEW_ONLY_DETECTION_INTERVAL_MS; + if (recognize || previewOnly) { + frameProcessor = processor; + framePreviewSize = previewSize; + frameGeneration = sessionGeneration; + } + if (previewOnly) lastPreviewOnlyDetectionAt = now; + } + } + if ((!recognize && !previewOnly) || !frameInFlight.compareAndSet(false, true)) { + try { + callbackCamera.addCallbackBuffer(data); + } catch (RuntimeException ignored) { + } + return; + } + try { + final boolean doRecognize = recognize; + final FaceDiscernViewModel expectedProcessor = frameProcessor; + final Camera.Size expectedPreviewSize = framePreviewSize; + final long expectedGeneration = frameGeneration; + recognitionExecutor.execute(() -> { + try { + processFrame( + data, + doRecognize, + callbackCamera, + expectedProcessor, + expectedPreviewSize, + expectedGeneration); + } finally { + // 只有成功取得 CAS 的任务可以释放标记,状态切换不得提前清零并让第二帧并发入队。 + frameInFlight.set(false); + try { + callbackCamera.addCallbackBuffer(data); + } catch (RuntimeException ignored) { + } + } + }); + } catch (RejectedExecutionException ignored) { + frameInFlight.set(false); + try { + callbackCamera.addCallbackBuffer(data); + } catch (RuntimeException ignoredBufferError) { + } + } + } + + /** + * 处理识别帧或热预览检测帧;两种模式复用同一执行器和处理锁,禁止并发调用虹软引擎。 + * + * @param data Camera1 回传的 NV21 原始帧 + * @param doRecognize true 时允许特征比对;false 时只获取人脸坐标用于画框 + * @param expectedCamera 帧入队时所属的 Camera,冷重启后必须与当前实例一致 + * @param expectedProcessor 帧入队时所属的虹软处理器 + * @param expectedPreviewSize 帧入队时使用的 NV21 尺寸 + * @param expectedGeneration 帧入队时所属的业务会话代次 + */ + private void processFrame( + byte[] data, + boolean doRecognize, + Camera expectedCamera, + FaceDiscernViewModel expectedProcessor, + Camera.Size expectedPreviewSize, + long expectedGeneration) { + try { + List> boxes; + synchronized (processorLock) { + if (!isCurrentFrameProcessor( + expectedCamera, + expectedProcessor, + expectedGeneration, + doRecognize)) return; + List faces = expectedProcessor.onPreviewFrame(data, doRecognize); + // FaceHelper 返回内部可变列表;必须在处理锁内复制坐标和状态,避免 finish/start 清理列表时产生空框或并发遍历。 + boxes = buildFaceBoxes(faces, expectedPreviewSize, expectedProcessor); + } + emitFaceBoxes(boxes, expectedGeneration, doRecognize); + } catch (Exception error) { + Log.e(TAG, "处理人脸预览帧失败", error); + handleFatalFailure("人脸识别引擎异常,请重试", "RECOGNITION_ENGINE_ERROR", expectedGeneration); + } + } + + /** + * 判断当前状态是否允许处理指定模式的 Camera 帧;调用方必须持有 sessionLock。 + * + * @param doRecognize true 表示业务识别帧,false 表示结果期热预览检测帧 + * @return 当前处理器、会话与预览输出均仍有效时返回 true + */ + private boolean canProcessFrameLocked(boolean doRecognize) { + if (doRecognize) { + return state == State.DETECTING && activeSessionId != null; + } + return state == State.WARM_IDLE + && activeSessionId == null + && warmResourcesReady + && previewRenderer != null + && previewRenderer.hasActiveOutputs(); + } + + /** + * 在耗时检测前二次校验处理器与会话代次,防止 finish/start 过渡帧串入新状态。 + * + * @param expectedCamera 任务创建时持有的 Camera + * @param expectedProcessor 任务创建时持有的虹软处理器 + * @param expectedGeneration 任务创建时的业务会话代次 + * @param doRecognize true 表示业务识别,false 表示仅检测 + * @return 任务仍属于当前状态时返回 true + */ + private boolean isCurrentFrameProcessor( + Camera expectedCamera, + FaceDiscernViewModel expectedProcessor, + long expectedGeneration, + boolean doRecognize) { + synchronized (sessionLock) { + return camera == expectedCamera + && processor == expectedProcessor + && sessionGeneration == expectedGeneration + && canProcessFrameLocked(doRecognize); + } + } + + /** + * 将虹软当前帧结果复制为 Flutter 通道使用的归一化框。 + * 相似度和活体状态只决定颜色与标签,不参与框的过滤。 + * + * @param faces 虹软当前帧检测到的全部人脸 + * @param currentPreviewSize NV21 原始预览尺寸 + * @param currentProcessor 当前虹软处理器,用于读取最近识别状态 + * @return 与内部 FaceHelper 列表生命周期无关的人脸框快照 + */ + private List> buildFaceBoxes( + List faces, + Camera.Size currentPreviewSize, + FaceDiscernViewModel currentProcessor) { + List> boxes = new ArrayList<>(); + if (faces == null) return boxes; + for (FacePreviewInfo face : faces) { + // 传原始 NV21 坐标,Flutter 再与 Texture 使用同一套旋转/镜像变换,避免框和画面各自补偿后错位。 + Rect rect = face.getFaceInfoRgb() == null ? null : face.getFaceInfoRgb().getRect(); + if (rect == null) continue; + Map box = new HashMap<>(); + box.put("trackId", face.getTrackId()); + box.put("left", clamp(rect.left / (double) currentPreviewSize.width)); + box.put("top", clamp(rect.top / (double) currentPreviewSize.height)); + box.put("right", clamp(rect.right / (double) currentPreviewSize.width)); + box.put("bottom", clamp(rect.bottom / (double) currentPreviewSize.height)); + Float similarity = currentProcessor.getFaceSimilarity(face.getTrackId()); + if (similarity != null) box.put("similarity", similarity.doubleValue()); + box.put("recognizeStatus", recognizeStatusName(currentProcessor.getFaceRecognizeStatus(face.getTrackId()))); + box.put("livenessStatus", livenessStatusName(currentProcessor.getFaceLiveness(face.getTrackId()))); + boxes.add(box); + } + return boxes; + } + + /** + * 发送稳定后的人脸框。短暂空帧或人数下降会继续沿用上一组框,持续超过宽限才清除。 + * + * @param detectedBoxes 当前检测帧复制出的全部人脸框 + * @param frameGeneration 当前处理任务的会话代次 + * @param doRecognize true 表示活动识别会话,false 表示 WARM_IDLE 仅检测预览 + */ + private void emitFaceBoxes( + List> detectedBoxes, + long frameGeneration, + boolean doRecognize) { + long now = SystemClock.elapsedRealtime(); + synchronized (sessionLock) { + if (sessionGeneration != frameGeneration || !canProcessFrameLocked(doRecognize)) return; + if (now - lastFaceBoxEmitAt < FACE_BOX_THROTTLE_MS) return; + lastFaceBoxEmitAt = now; + List> visibleBoxes = stabilizeFaceBoxesLocked(detectedBoxes, now); + emitState(doRecognize ? "请正对摄像头" : "摄像头预览中", visibleBoxes, null); + } + } + + /** + * 按虹软跟踪编号分别合并当前检测框与短时缓存,避免一个人漏检时冻结其他人的新坐标。 + * 调用方必须持有 sessionLock。 + * + * @param detectedBoxes 当前帧检测框,不包含任何相似度过滤 + * @param now 当前单调业务时间戳,单位为毫秒 + * @return 本帧应向主副屏展示的稳定框列表 + */ + private List> stabilizeFaceBoxesLocked( + List> detectedBoxes, + long now) { + List> visibleBoxes = new ArrayList<>(detectedBoxes); + Map> detectedByTrackId = new HashMap<>(); + Map nextSeenAt = new HashMap<>(); + for (Map box : detectedBoxes) { + Integer trackId = faceTrackId(box); + if (trackId == null) continue; + detectedByTrackId.put(trackId, box); + nextSeenAt.put(trackId, now); + } + for (Map previousBox : lastFaceBoxes) { + Integer trackId = faceTrackId(previousBox); + if (trackId == null || detectedByTrackId.containsKey(trackId)) continue; + Long lastSeenAt = lastFaceBoxSeenAt.get(trackId); + if (lastSeenAt != null && now - lastSeenAt < FACE_BOX_MISS_GRACE_MS) { + visibleBoxes.add(previousBox); + nextSeenAt.put(trackId, lastSeenAt); + } + } + lastFaceBoxes = visibleBoxes; + lastFaceBoxSeenAt = nextSeenAt; + return lastFaceBoxes; + } + + /** + * 读取原生框快照中的虹软跟踪编号,仅用于进程内稳定框,不属于 Flutter 必填协议字段。 + * + * @param box 单张人脸框快照 + * @return 可用于逐人缓存的跟踪编号;历史或异常数据缺失时返回 null + */ + private Integer faceTrackId(Map box) { + Object value = box.get("trackId"); + return value instanceof Number ? ((Number) value).intValue() : null; + } + + /** + * 返回仍在漏检宽限内的最近人脸框,超时则同步清理缓存。 + * 调用方必须持有 sessionLock。 + * + * @param now 当前时间戳,单位为毫秒 + * @return 可安全用于初始化或资源状态事件的人脸框 + */ + private List> recentFaceBoxesLocked(long now) { + return stabilizeFaceBoxesLocked(new ArrayList<>(), now); + } + + /** + * 返回适合新订阅者的当前框快照;成功态保留冻结结果,实时态只保留仍在漏检宽限内的框。 + * 调用方必须持有 sessionLock。 + * + * @param now 当前单调业务时间戳,单位为毫秒 + * @return 与内部列表解耦、可直接放入通道 Map 的人脸框列表 + */ + private List> currentStateFaceBoxesLocked(long now) { + if (state == State.SUCCESS) { + return new ArrayList<>(lastFaceBoxes); + } + if (state == State.INITIALIZING || state == State.DETECTING || state == State.WARM_IDLE) { + return new ArrayList<>(recentFaceBoxesLocked(now)); + } + return new ArrayList<>(); + } + + /** 清空人脸框和漏检计时;取消、冷释放及显式失败不得继续展示旧人员。 */ + private void clearFaceBoxCacheLocked() { + lastFaceBoxes = new ArrayList<>(); + lastFaceBoxSeenAt = new HashMap<>(); + } + + private double clamp(double value) { + return Math.max(0d, Math.min(1d, value)); + } + + private String recognizeStatusName(Integer status) { + if (status == null) return "waiting"; + if (status == RequestFeatureStatus.SEARCHING) return "comparing"; + if (status == RequestFeatureStatus.SUCCEED) return "success"; + if (status == RequestFeatureStatus.FAILED) return "failed"; + return "waiting"; + } + + private String livenessStatusName(Integer status) { + if (status == null) return "unknown"; + if (status == LivenessInfo.ALIVE) return "alive"; + if (status == LivenessInfo.NOT_ALIVE) return "notAlive"; + if (status == RequestLivenessStatus.ANALYZING) return "analyzing"; + return "unknown"; + } + + /** + * 冻结当前成功会话并在单线程识别执行器中生成现场证据,完成后才向会话所有者回传结果。 + * + * @param rawResult 虹软返回的命中用户和相似度字段,不含原始帧 + * @param callbackGeneration 回调所属会话代次,用于拒绝迟到结果 + * @param frameSnapshot 实际命中目标的 NV21 与人脸框,非收银兼容链路允许为空 + */ + private void onRecognized(HashMap rawResult, long callbackGeneration, + RecognitionFrameSnapshot frameSnapshot) { + final String resultSessionId; + final String resultScene; + final double resultThreshold; + final int resultRotation; + final boolean resultMirror; + final Listener resultOwner; + synchronized (sessionLock) { + if (callbackGeneration != sessionGeneration || state != State.DETECTING || activeSessionId == null) return; + state = State.SUCCESS; + List> successBoxes = new ArrayList<>(); + for (Map box : lastFaceBoxes) { + Map successBox = new HashMap<>(box); + successBox.put("recognizeStatus", "success"); + successBoxes.add(successBox); + } + lastFaceBoxes = successBoxes; + // 只冻结预览输出,不释放 Camera 热资源;成功框和相似度保留时长由 Flutter 面板控制。 + if (previewRenderer != null) previewRenderer.setFrozen(true); + emitState("识别成功,正在读取信息", successBoxes, null); + resultSessionId = activeSessionId; + resultScene = activeScene; + resultThreshold = processor == null ? 0.8d : (double) processor.getSimilarityThreshold(); + resultRotation = displayOrientation; + resultMirror = mirror; + resultOwner = activeOwner; + } + rawResult.put("sessionId", resultSessionId); + rawResult.put("similarityThreshold", resultThreshold); + recognitionExecutor.execute(() -> { + FaceEvidenceImageWriter.Result evidence = null; + // 收银支付和预约菜单核销都需要保留识别成功帧;普通身份核验等其他场景不写入长期证据候选。 + if ("cashier".equals(resultScene) || "reservation".equals(resultScene)) { + evidence = FaceEvidenceImageWriter.write( + context.getFilesDir(), frameSnapshot, resultRotation, resultMirror, + frameSnapshot == null || frameSnapshot.getFaceInfo() == null + ? 0 : frameSnapshot.getFaceInfo().getFaceId()); + rawResult.put("capturedAt", evidence.capturedAt); + if (evidence.filePath != null) { + rawResult.put("evidenceFilePath", evidence.filePath); + rawResult.put("evidenceHash", evidence.hash); + rawResult.put("evidenceSize", evidence.size); + rawResult.put("evidenceWidth", evidence.width); + rawResult.put("evidenceHeight", evidence.height); + } else { + rawResult.put("evidenceError", evidence.error == null ? "现场图片生成失败" : evidence.error); + } + } + synchronized (sessionLock) { + if (callbackGeneration != sessionGeneration + || !Objects.equals(resultSessionId, activeSessionId) + || state != State.SUCCESS) { + if (evidence != null && evidence.filePath != null) new java.io.File(evidence.filePath).delete(); + return; + } + } + if (resultOwner != null) resultOwner.onRecognitionResult(new Gson().toJson(rawResult)); + }); + } + + private void handleFatalFailure(String message, String errorCode, long expectedGeneration) { + boolean preserveSession; + synchronized (sessionLock) { + if (expectedGeneration != 0L && expectedGeneration != sessionGeneration) return; + preserveSession = activeSessionId != null; + if (preserveSession) { + state = State.FAILED; + clearFaceBoxCacheLocked(); + emitState(message, null, errorCode); + } + } + // 致命错误释放 Camera/会话级引擎,但保留 Session 编号,主屏点击重试时执行冷启动。 + releaseResources(!preserveSession, !preserveSession); + } + + private void resetProcessorForSession(String sessionId) throws Exception { + FaceDiscernViewModel currentProcessor; + long generation; + synchronized (sessionLock) { + ensureOwner(sessionId); + currentProcessor = processor; + if (currentProcessor == null) throw new IllegalStateException("人脸识别引擎未就绪"); + generation = ++sessionGeneration; + } + synchronized (processorLock) { + currentProcessor.beginSession(generation); + } + FacePreviewRenderer renderer; + synchronized (sessionLock) { + renderer = previewRenderer; + lastFaceBoxEmitAt = 0; + lastPreviewOnlyDetectionAt = 0; + } + if (renderer != null) renderer.setFrozen(false); + } + + /** + * 先在状态锁内切断 Session 代次,再到锁外停止虹软处理,避免异步回调和取消操作形成反向锁等待。 + */ + private FaceDiscernViewModel detachActiveSession() { + sessionGeneration++; + FaceDiscernViewModel processorToStop = processor; + activeSessionId = null; + activeScene = null; + activeOwner = null; + // 若旧帧仍在执行,必须由该任务的 finally 自行释放标记;提前清零会让 Camera 重复排队并占满回调缓冲。 + state = warmResourcesReady ? State.WARM_IDLE : State.COLD; + scheduleWarmRelease(); + return processorToStop; + } + + private void stopProcessor(FaceDiscernViewModel processorToStop) { + if (processorToStop == null) return; + synchronized (processorLock) { + processorToStop.stop(); + } + } + + private void scheduleWarmRelease() { + cancelWarmRelease(); + if (warmResourcesReady) warmReleaseHandler.postDelayed(warmReleaseTask, WARM_TIMEOUT_MS); + } + + private void cancelWarmRelease() { + warmReleaseHandler.removeCallbacks(warmReleaseTask); + } + + @SuppressWarnings("deprecation") + private void releaseWarmResources() { + releaseResources(true, true); + } + + @SuppressWarnings("deprecation") + private void releaseResources(boolean clearActiveSession, boolean resetRevision) { + Camera cameraToRelease; + FaceDiscernViewModel processorToRelease; + FacePreviewRenderer rendererToRelease; + synchronized (sessionLock) { + cancelWarmRelease(); + sessionGeneration++; + cameraToRelease = camera; + processorToRelease = processor; + rendererToRelease = previewRenderer; + camera = null; + processor = null; + previewRenderer = null; + previewSize = null; + warmResourcesReady = false; + recognitionConfigurationDirty = false; + clearFaceBoxCacheLocked(); + lastFaceBoxEmitAt = 0L; + lastPreviewOnlyDetectionAt = 0L; + if (clearActiveSession) { + activeSessionId = null; + activeScene = null; + activeOwner = null; + state = State.COLD; + } + if (resetRevision) revision = 0; + } + if (cameraToRelease != null) { + try { + cameraToRelease.setErrorCallback(null); + cameraToRelease.setPreviewCallbackWithBuffer(null); + cameraToRelease.stopPreview(); + } catch (Exception ignored) { + } + try { + cameraToRelease.release(); + } catch (Exception ignored) { + } + } + if (processorToRelease != null) { + synchronized (processorLock) { + processorToRelease.destroy(); + } + } + if (rendererToRelease != null) rendererToRelease.release(); + // Camera 真正释放后再广播资源快照,避免顶栏在 20 分钟超时后仍显示为开启。 + emitResourceState(state == State.COLD ? "摄像头已关闭" : "摄像头资源已释放"); + } + + private void ensureOwner(String sessionId) { + if (activeSessionId == null || !Objects.equals(activeSessionId, sessionId)) { + throw new IllegalStateException("当前人脸会话已结束"); + } + } + + private boolean isRecognitionConfigurationDirty() { + synchronized (sessionLock) { + return recognitionConfigurationDirty; + } + } + + private void emitState(String message, List> boxes, String errorCode) { + Map event = snapshot(); + event.put("message", message); + event.put("revision", ++revision); + event.put("eventSequence", ++eventSequence); + event.put("faceBoxes", boxes == null ? new ArrayList<>() : boxes); + if (errorCode != null) event.put("errorCode", errorCode); + for (Listener listener : listeners) listener.onSessionEvent(event); + } + + /** + * 资源状态不增加业务 revision,活动 Session 会忽略它,常驻设备状态仍能获知 Camera 开关变化。 + */ + private void emitResourceState(String message) { + Map event; + synchronized (sessionLock) { + event = snapshot(); + event.put("eventSequence", ++eventSequence); + event.put("message", message); + event.put("faceBoxes", state == State.WARM_IDLE + ? recentFaceBoxesLocked(SystemClock.elapsedRealtime()) + : new ArrayList<>()); + } + for (Listener listener : listeners) listener.onSessionEvent(event); + } + + /** + * 构建当前原生会话的通道快照。 + * 最大脸策略与活体、阈值一起透传给 Flutter,使画框语义始终与实际识别链一致。 + * + * @return 可直接发送到 Flutter EventChannel 的状态 Map + */ + private Map snapshot() { + Map result = new HashMap<>(); + result.put("sessionId", activeSessionId == null ? "" : activeSessionId); + result.put("scene", activeScene == null ? "" : activeScene); + result.put("state", state.code); + result.put("revision", revision); + result.put("eventSequence", eventSequence); + result.put("width", previewSize == null ? TARGET_WIDTH : previewSize.width); + result.put("height", previewSize == null ? TARGET_HEIGHT : previewSize.height); + result.put("rotation", displayOrientation); + result.put("mirror", mirror); + result.put("warm", warmResourcesReady); + FaceDiscernViewModel currentProcessor = processor; + result.put("livenessEnabled", currentProcessor != null && currentProcessor.isLivenessEnabled()); + result.put("keepMaxFace", currentProcessor != null && currentProcessor.isKeepMaxFaceEnabled()); + result.put("similarityThreshold", currentProcessor == null + ? 0.8d : (double) currentProcessor.getSimilarityThreshold()); + return result; + } + + @SuppressWarnings("deprecation") + private int getDisplayRotationDegrees() { + WindowManager windowManager = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + int rotation = windowManager == null ? Surface.ROTATION_0 : windowManager.getDefaultDisplay().getRotation(); + switch (rotation) { + case Surface.ROTATION_90: + return 90; + case Surface.ROTATION_180: + return 180; + case Surface.ROTATION_270: + return 270; + default: + return 0; + } + } + + @SuppressWarnings("deprecation") + private int findRgbCameraId() { + int fallback = -1; + int cameraCount = Camera.getNumberOfCameras(); + Camera.CameraInfo info = new Camera.CameraInfo(); + for (int i = 0; i < cameraCount; i++) { + Camera.getCameraInfo(i, info); + if (fallback < 0) fallback = i; + // 原虹软插件的 PreviewConfig 明确约定 BACK 为 RGB、FRONT 为 IR。 + // 双目终端的 facing 表示设备枚举角色而非用户朝向,不能再把 FRONT 当作彩色相机。 + if (info.facing == PreviewConfig.DEFAULT_RGB_CAMERA_ID) { + Log.i(TAG, "选择彩色 RGB 摄像头 cameraId=" + i + ";facing=BACK;cameraCount=" + cameraCount); + return i; + } + } + if (fallback < 0) throw new IllegalStateException("未发现可用摄像头"); + Log.w(TAG, "未发现约定的 BACK RGB 摄像头,回退 cameraId=" + fallback + ";cameraCount=" + cameraCount); + return fallback; + } + + @SuppressWarnings("deprecation") + private Camera.Size choosePreviewSize(List sizes) { + if (sizes == null || sizes.isEmpty()) return camera.getParameters().getPreviewSize(); + Camera.Size best = sizes.get(0); + long targetArea = (long) TARGET_WIDTH * TARGET_HEIGHT; + double targetRatio = TARGET_WIDTH / (double) TARGET_HEIGHT; + double bestScore = Double.MAX_VALUE; + for (Camera.Size size : sizes) { + double ratioPenalty = Math.abs(size.width / (double) size.height - targetRatio) * targetArea; + double areaPenalty = Math.abs((long) size.width * size.height - targetArea); + double score = ratioPenalty * 4 + areaPenalty; + if (score < bestScore) { + best = size; + bestScore = score; + } + } + return best; + } + + public static final class SessionBusyException extends Exception { + private final String activeSessionId; + + SessionBusyException(String activeSessionId) { + super("已有进行中的人脸会话"); + this.activeSessionId = activeSessionId; + } + + public String getActiveSessionId() { + return activeSessionId; + } + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java index bf33e23..f90b3ba 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/FaceHelper.java @@ -33,6 +33,7 @@ import io.flutter.plugins.facediscern.tool.face.model.FaceEntity; import io.flutter.plugins.facediscern.tool.face.model.FacePreviewInfo; import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfiguration; import io.flutter.plugins.facediscern.tool.face.model.RecognizeInfo; +import io.flutter.plugins.facediscern.tool.face.model.RecognitionFrameSnapshot; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -69,6 +70,7 @@ public class FaceHelper implements FaceListener { * 识别结果的回调 */ private RecognizeCallback recognizeCallback; + private volatile long sessionGeneration; /** @@ -76,6 +78,9 @@ public class FaceHelper implements FaceListener { */ private ConcurrentHashMap recognizeInfoMap = new ConcurrentHashMap<>(); + /** 每个跟踪目标最近一次特征提取所用的帧快照,仅在当前会话内短暂持有。 */ + private ConcurrentHashMap recognitionFrameMap = new ConcurrentHashMap<>(); + private CompositeDisposable getFeatureDelayedDisposables = new CompositeDisposable(); private CompositeDisposable delayFaceTaskCompositeDisposable = new CompositeDisposable(); /** @@ -162,6 +167,12 @@ public class FaceHelper implements FaceListener { private List currentTrackIdList = new ArrayList<>(); private List facePreviewInfoList = new ArrayList<>(); + /** + * “仅识别最大人脸”模式下当前允许进入异步识别链的跟踪编号。 + * 该值随每一帧的面积排序更新,用于拦截上一目标已排队但尚未完成的结果。 + */ + private volatile Integer currentRecognitionTargetTrackId; + private Disposable timerDisposable; private FaceHelper(Builder builder) { @@ -288,7 +299,13 @@ public class FaceHelper implements FaceListener { stop(); } - public void stop() { + public synchronized void stop() { + sessionGeneration++; + clearTrackingState(); + } + + private void clearTrackingState() { + currentRecognitionTargetTrackId = null; if (faceInfoList != null) { faceInfoList.clear(); } @@ -298,17 +315,33 @@ public class FaceHelper implements FaceListener { if (recognizeInfoMap != null) { recognizeInfoMap.clear(); } + if (recognitionFrameMap != null) { + recognitionFrameMap.clear(); + } if (facePreviewInfoList != null) { facePreviewInfoList.clear(); } } + /** + * 新顾客开始时复用既有识别线程和 FaceEngine,只清除跟踪状态与延迟任务。 + * 异步特征任务会校验代次,上一位顾客已在执行的任务即使晚到也不会上报结果。 + */ + public synchronized void beginSession(long generation, RecognizeCallback callback) { + sessionGeneration = generation; + recognizeCallback = callback; + if (getFeatureDelayedDisposables != null) getFeatureDelayedDisposables.clear(); + if (delayFaceTaskCompositeDisposable != null) delayFaceTaskCompositeDisposable.clear(); + if (timerDisposable != null && !timerDisposable.isDisposed()) timerDisposable.dispose(); + clearTrackingState(); + } + /** * 处理帧数据 * * @param rgbNv21 可见光相机预览回传的NV21数据 * @param irNv21 红外相机预览回传的NV21数据 - * @param doRecognize 是否进行识别 + * @param doRecognize 是否进入口罩、活体、特征提取和比对链;false 时只做人脸检测与跟踪 * @return 实时人脸处理结果,封装添加了一个trackId,trackId的获取依赖于faceId,用于记录人脸序号并保存 */ public List onPreviewFrame(@NonNull byte[] rgbNv21, @Nullable byte[] irNv21, boolean doRecognize) { @@ -321,14 +354,15 @@ public class FaceHelper implements FaceListener { onFail(new Exception("detectFaces failed,code is " + code)); return facePreviewInfoList; } - if (recognizeConfiguration.isKeepMaxFace()) { - keepMaxFace(faceInfoList); - } refreshTrackId(faceInfoList); if (faceInfoList.isEmpty()) { + updateRecognitionTarget(null); + clearLeftFace(facePreviewInfoList); return facePreviewInfoList; } - if (!onlyDetectLiveness) { + // 结果展示期只需要实时框,跳过口罩与后续识别链可以降低常驻 Camera 的 CPU 占用, + // 也避免口罩引擎的单帧异常把已经成功检测到的人脸误报为空列表。 + if (doRecognize && !onlyDetectLiveness) { code = maskEngine.process(rgbNv21, previewSize.width, previewSize.height, FaceEngine.CP_PAF_NV21, faceInfoList, FaceEngine.ASF_MASK_DETECT); if (code == ErrorInfo.MOK) { @@ -406,6 +440,7 @@ public class FaceHelper implements FaceListener { } if (!contained) { RecognizeInfo recognizeInfo = recognizeInfoMap.remove(key); + recognitionFrameMap.remove(key); if (recognizeInfo != null) { recognizeCallback.onNoticeChanged(""); // 人脸离开时,通知特征提取线程,避免一直等待活体结果 @@ -417,13 +452,29 @@ public class FaceHelper implements FaceListener { } } + /** + * 对当前帧允许参与识别的人脸执行质量、活体和特征提取流程。 + * “仅识别最大人脸”只收窄识别列表,不修改原始预览列表,因此 Flutter 仍能绘制其他人脸。 + * + * @param rgbNv21 可见光相机当前帧数据 + * @param irNv21 红外相机当前帧数据,单摄像头时为空 + * @param facePreviewInfoList 当前帧全部人脸预览信息 + */ private void doRecognize(byte[] rgbNv21, byte[] irNv21, List facePreviewInfoList) { if (facePreviewInfoList != null && !facePreviewInfoList.isEmpty() && previewSize != null) { - for (FaceRecognizeFilter faceRecognizeFilter : faceRecognizeFilterList) { - faceRecognizeFilter.filter(facePreviewInfoList); + List recognitionFaceList = recognitionFaces(facePreviewInfoList); + if (recognitionFaceList.isEmpty()) { + updateRecognitionTarget(null); + return; } - for (int i = 0; i < facePreviewInfoList.size(); i++) { - FacePreviewInfo facePreviewInfo = facePreviewInfoList.get(i); + updateRecognitionTarget(recognizeConfiguration.isKeepMaxFace() + ? recognitionFaceList.get(0).getTrackId() + : null); + for (FaceRecognizeFilter faceRecognizeFilter : faceRecognizeFilterList) { + faceRecognizeFilter.filter(recognitionFaceList); + } + for (int i = 0; i < recognitionFaceList.size(); i++) { + FacePreviewInfo facePreviewInfo = recognitionFaceList.get(i); if (!facePreviewInfo.isQualityPass()) { continue; } @@ -466,9 +517,69 @@ public class FaceHelper implements FaceListener { ); } } + } else { + updateRecognitionTarget(null); } } + /** + * 根据当前策略返回参与识别的人脸列表;最大脸按矩形面积而不是单边长度判断。 + * + * @param previewFaces 当前帧全部预览人脸 + * @return 多人模式返回原列表,仅识别最大人脸时返回只含最大面积人脸的新列表 + */ + private List recognitionFaces(List previewFaces) { + if (!recognizeConfiguration.isKeepMaxFace() || previewFaces.size() <= 1) { + return previewFaces; + } + FacePreviewInfo largestFace = previewFaces.get(0); + for (FacePreviewInfo face : previewFaces) { + if (faceArea(face.getFaceInfoRgb().getRect()) > faceArea(largestFace.getFaceInfoRgb().getRect())) { + largestFace = face; + } + } + List result = new ArrayList<>(1); + result.add(largestFace); + return result; + } + + /** + * 更新当前最大脸目标,并将刚退出目标位的人脸恢复为可重试状态。 + * 这样目标再次成为最大脸时不会停留在已经没有异步任务的 SEARCHING 状态。 + * + * @param trackId 当前最大脸跟踪编号;无目标或多人识别模式时为空 + */ + private void updateRecognitionTarget(@Nullable Integer trackId) { + Integer previousTrackId = currentRecognitionTargetTrackId; + if (Objects.equals(previousTrackId, trackId)) { + return; + } + currentRecognitionTargetTrackId = trackId; + if (recognizeConfiguration.isKeepMaxFace() && previousTrackId != null) { + RecognizeInfo previousInfo = recognizeInfoMap.get(previousTrackId); + if (previousInfo != null) { + previousInfo.setRecognizeStatus(RequestFeatureStatus.TO_RETRY); + previousInfo.setLiveness(LivenessInfo.UNKNOWN); + synchronized (previousInfo.getWaitLock()) { + previousInfo.getWaitLock().notifyAll(); + } + } + } + } + + /** + * 判断异步任务是否仍属于当前会话且仍是允许识别的目标脸。 + * + * @param trackId 异步任务对应的人脸跟踪编号 + * @param generation 创建异步任务时的业务会话代次 + * @return 任务结果仍可进入识别回调时返回 true + */ + private boolean isCurrentRecognitionTarget(@Nullable Integer trackId, long generation) { + return generation == sessionGeneration + && (!recognizeConfiguration.isKeepMaxFace() + || Objects.equals(currentRecognitionTargetTrackId, trackId)); + } + @Override public void onFail(Exception e) { Log.e(TAG, "onFail:" + e.getMessage()); @@ -492,6 +603,9 @@ public class FaceHelper implements FaceListener { @Override public void onFaceFeatureInfoGet(@Nullable FaceFeature faceFeature, Integer trackId, Integer errorCode) { + if (!isCurrentRecognitionTarget(trackId, sessionGeneration)) { + return; + } //FR成功 RecognizeInfo recognizeInfo = getRecognizeInfo(recognizeInfoMap, trackId); if (faceFeature != null) { @@ -610,6 +724,9 @@ public class FaceHelper implements FaceListener { @Override public void onFaceLivenessInfoGet(@Nullable LivenessInfo livenessInfo, Integer trackId, Integer errorCode) { + if (!isCurrentRecognitionTarget(trackId, sessionGeneration)) { + return; + } if (livenessInfo != null) { int liveness = livenessInfo.getLiveness(); Log.i(TAG, "onFaceLivenessInfoGet liveness:" + liveness); @@ -649,14 +766,20 @@ public class FaceHelper implements FaceListener { } private void searchFace(final FaceFeature faceFeature, final Integer trackId) { + if (!isCurrentRecognitionTarget(trackId, sessionGeneration)) { + return; + } CompareResult compareResult = FaceServer.getInstance().searchFaceFeature(faceFeature); if (compareResult == null || compareResult.getFaceEntity() == null) { retryRecognizeDelayed(trackId); return; } compareResult.setTrackId(trackId); + // 相似度属于当前跟踪人脸,先保存再判断阈值,预览框才能展示真实的最近一次比对结果。 + setSimilarity(trackId, compareResult.getSimilar()); boolean pass = compareResult.getSimilar() > recognizeConfiguration.getSimilarThreshold(); - recognizeCallback.onRecognized(compareResult, getRecognizeInfo(recognizeInfoMap, trackId).getLiveness(), pass); + recognizeCallback.onRecognized(compareResult, getRecognizeInfo(recognizeInfoMap, trackId).getLiveness(), pass, + recognitionFrameMap.get(trackId)); if (pass) { String msg = "识别通过"; Log.i(TAG, "用户名字:" + compareResult.getFaceEntity().getUserName() + ";人脸id:" + compareResult.getFaceEntity().getFaceId() + ";"); @@ -688,6 +811,7 @@ public class FaceHelper implements FaceListener { private Integer trackId; private byte[] nv21Data; private int isMask; + private final long generation; /** * 异步特征提取任务的构造函数 @@ -699,20 +823,26 @@ public class FaceHelper implements FaceListener { * @param format 图像格式 */ private FaceRecognizeRunnable(byte[] nv21Data, FacePreviewInfo facePreviewInfo, int width, int height, int format) { + this.generation = sessionGeneration; if (nv21Data == null) { return; } - this.nv21Data = nv21Data; + // Camera1 使用回调缓冲区复用帧内存,异步特征提取必须持有自己的快照, + // 否则下一帧会覆盖当前顾客数据,连续识别时可能出现错人或提取失败。 + this.nv21Data = Arrays.copyOf(nv21Data, nv21Data.length); this.faceInfo = new FaceInfo(facePreviewInfo.getFaceInfoRgb()); this.width = width; this.height = height; this.format = format; this.trackId = facePreviewInfo.getTrackId(); this.isMask = facePreviewInfo.getMask(); + recognitionFrameMap.put(this.trackId, new RecognitionFrameSnapshot(this.nv21Data, this.faceInfo, + this.width, this.height, System.currentTimeMillis())); } @Override public void run() { + if (!isCurrentRecognitionTarget(trackId, generation)) return; if (nv21Data != null) { if (frEngine != null) { if (recognizeConfiguration.isEnableImageQuality()) { @@ -763,14 +893,20 @@ public class FaceHelper implements FaceListener { } Log.i(TAG, "frTime:" + (System.currentTimeMillis() - irStartTime) + "ms"); if (frCode == ErrorInfo.MOK) { - onFaceFeatureInfoGet(faceFeature, trackId, frCode); + synchronized (FaceHelper.this) { + if (!isCurrentRecognitionTarget(trackId, generation)) return; + onFaceFeatureInfoGet(faceFeature, trackId, frCode); + } } else { onFaceFail(frCode, "fr failed errorCode is " + frCode); } } private void onFaceFail(int code, String errorMsg) { - onFaceFeatureInfoGet(null, trackId, code); + synchronized (FaceHelper.this) { + if (!isCurrentRecognitionTarget(trackId, generation)) return; + onFaceFeatureInfoGet(null, trackId, code); + } onFail(new Exception(errorMsg)); } } @@ -788,10 +924,13 @@ public class FaceHelper implements FaceListener { private LivenessType livenessType; private Object waitLock; + /** 创建活体任务时的会话代次,用于丢弃上一位顾客或上一目标脸的迟到结果。 */ + private final long generation; + /** * 异步活体任务的构造函数 * - * @param nv21Data 可见光或红外图像数据 + * @param nv21Data 可见光或红外图像数据;构造时复制,避免 Camera 复用缓冲区覆盖异步任务 * @param faceInfo 可见光人脸检测得到的人脸信息 * @param width 图像宽度 * @param height 图像高度 @@ -800,10 +939,11 @@ public class FaceHelper implements FaceListener { * @param waitLock 活体检测通过后,调用该对象的notifyAll函数,通知识别线程活体已通过 */ private FaceLivenessDetectRunnable(byte[] nv21Data, FacePreviewInfo faceInfo, int width, int height, int format, LivenessType livenessType, Object waitLock) { + this.generation = sessionGeneration; if (nv21Data == null) { return; } - this.nv21Data = nv21Data; + this.nv21Data = Arrays.copyOf(nv21Data, nv21Data.length); this.faceInfo = new FaceInfo(faceInfo.getFaceInfoRgb()); this.width = width; this.height = height; @@ -815,6 +955,7 @@ public class FaceHelper implements FaceListener { @Override public void run() { + if (!isCurrentRecognitionTarget(trackId, generation)) return; if (nv21Data != null) { if (flEngine != null) { processLiveness(); @@ -864,6 +1005,7 @@ public class FaceHelper implements FaceListener { } if (flCode == ErrorInfo.MOK && !livenessInfoList.isEmpty()) { + if (!isCurrentRecognitionTarget(trackId, generation)) return; onFaceLivenessInfoGet(livenessInfoList.get(0), trackId, flCode); if (livenessInfoList.get(0).getLiveness() == LivenessInfo.ALIVE) { synchronized (waitLock) { @@ -876,6 +1018,7 @@ public class FaceHelper implements FaceListener { } private void onProcessFail(int code, String msg) { + if (!isCurrentRecognitionTarget(trackId, generation)) return; onFaceLivenessInfoGet(null, trackId, code); onFail(new Exception(msg)); } @@ -1003,6 +1146,15 @@ public class FaceHelper implements FaceListener { return recognizeInfo == null ? null : recognizeInfo.getName(); } + public void setSimilarity(int trackId, float similarity) { + getRecognizeInfo(recognizeInfoMap, trackId).setSimilarity(similarity); + } + + public Float getSimilarity(int trackId) { + RecognizeInfo recognizeInfo = recognizeInfoMap.get(trackId); + return recognizeInfo == null ? null : recognizeInfo.getSimilarity(); + } + /** * 设置可识别区域(相对于View) @@ -1080,7 +1232,7 @@ public class FaceHelper implements FaceListener { } /** - * 保留ftFaceList中最大的人脸 + * 保留 ftFaceList 中面积最大的人脸。 * * @param ftFaceList 人脸追踪时,一帧数据的人脸信息 */ @@ -1090,7 +1242,7 @@ public class FaceHelper implements FaceListener { } FaceInfo maxFaceInfo = ftFaceList.get(0); for (FaceInfo faceInfo : ftFaceList) { - if (faceInfo.getRect().width() > maxFaceInfo.getRect().width()) { + if (faceArea(faceInfo.getRect()) > faceArea(maxFaceInfo.getRect())) { maxFaceInfo = faceInfo; } } @@ -1098,6 +1250,19 @@ public class FaceHelper implements FaceListener { ftFaceList.add(maxFaceInfo); } + /** + * 计算原始摄像头坐标中的有效矩形面积,异常或反向矩形按 0 处理。 + * + * @param rect 虹软返回的人脸矩形 + * @return 人脸矩形面积,单位为预览像素平方 + */ + private static long faceArea(@Nullable Rect rect) { + if (rect == null) { + return 0L; + } + return (long) Math.max(0, rect.width()) * Math.max(0, rect.height()); + } + public static final class Builder { private FaceEngine ftEngine; diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/RecognizeCallback.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/RecognizeCallback.java index ebda709..3560be1 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/RecognizeCallback.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/help/callback/RecognizeCallback.java @@ -2,6 +2,7 @@ package io.flutter.plugins.facediscern.tool.face.help.callback; import io.flutter.plugins.facediscern.tool.face.model.CompareResult; +import io.flutter.plugins.facediscern.tool.face.model.RecognitionFrameSnapshot; public interface RecognizeCallback { /** @@ -10,8 +11,10 @@ public interface RecognizeCallback { * @param compareResult 比对结果 * @param liveness 活体值 * @param similarPass 是否通过(依据设置的阈值) + * @param frameSnapshot 实际提取特征的 NV21 和目标人脸框;旧 Activity 链路允许为空 */ - void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass); + void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass, + RecognitionFrameSnapshot frameSnapshot); /** * 提示文字变更的回调 diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognitionFrameSnapshot.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognitionFrameSnapshot.java new file mode 100644 index 0000000..d83d4f1 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognitionFrameSnapshot.java @@ -0,0 +1,62 @@ +package io.flutter.plugins.facediscern.tool.face.model; + +import com.arcsoft.face.FaceInfo; + +import java.util.Arrays; + +/** + * 人脸特征提取使用的可见光帧快照。 + * + *

Camera1 会复用回调缓冲区,因此这里必须复制 NV21 和 FaceInfo,保证异步比对成功后 + * 仍然能裁剪出同一张实际命中目标的人脸图片。

+ */ +public final class RecognitionFrameSnapshot { + /** 原始 NV21 帧的独立副本。 */ + private final byte[] nv21; + + /** 该帧中实际参与特征提取的人脸框。 */ + private final FaceInfo faceInfo; + + /** NV21 原始宽度,单位为像素。 */ + private final int width; + + /** NV21 原始高度,单位为像素。 */ + private final int height; + + /** 相机回调采集时间,单位为 Unix 毫秒。 */ + private final long capturedAt; + + /** 创建一份不可变的人脸成功帧快照。 */ + public RecognitionFrameSnapshot(byte[] nv21, FaceInfo faceInfo, int width, int height, long capturedAt) { + this.nv21 = nv21 == null ? null : Arrays.copyOf(nv21, nv21.length); + this.faceInfo = faceInfo == null ? null : new FaceInfo(faceInfo); + this.width = width; + this.height = height; + this.capturedAt = capturedAt; + } + + /** 返回 NV21 快照副本,避免调用方修改共享内容。 */ + public byte[] getNv21() { + return nv21 == null ? null : Arrays.copyOf(nv21, nv21.length); + } + + /** 返回实际命中人脸框的独立副本。 */ + public FaceInfo getFaceInfo() { + return faceInfo == null ? null : new FaceInfo(faceInfo); + } + + /** 返回 NV21 原始宽度。 */ + public int getWidth() { + return width; + } + + /** 返回 NV21 原始高度。 */ + public int getHeight() { + return height; + } + + /** 返回相机帧采集时间。 */ + public long getCapturedAt() { + return capturedAt; + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java index e67f62e..96e41a4 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfiguration.java @@ -6,6 +6,21 @@ import com.arcsoft.face.LivenessParam; * 识别相关的配置项 */ public class RecognizeConfiguration { + public static final int DEFAULT_EXTRACT_RETRY_COUNT = 3; + public static final int DEFAULT_LIVENESS_RETRY_COUNT = 3; + public static final int DEFAULT_MAX_DETECT_FACES = 1; + public static final int DEFAULT_RECOGNIZE_FAILED_RETRY_INTERVAL = 0; + public static final int DEFAULT_LIVENESS_FAILED_RETRY_INTERVAL = 0; + public static final float DEFAULT_SIMILAR_THRESHOLD = 0.8F; + public static final float DEFAULT_IMAGE_QUALITY_NO_MASK_THRESHOLD = 0.49F; + public static final float DEFAULT_IMAGE_QUALITY_MASK_THRESHOLD = 0.29F; + /** 在库照片质量诊断默认阈值,只用于终端运维分类。 */ + public static final float DEFAULT_LIBRARY_FACE_QUALITY_THRESHOLD = 0.60F; + public static final float DEFAULT_RGB_LIVENESS_THRESHOLD = 0.5F; + public static final boolean DEFAULT_ENABLE_LIVENESS = false; + public static final boolean DEFAULT_ENABLE_IMAGE_QUALITY = true; + public static final boolean DEFAULT_KEEP_MAX_FACE = true; + /** * 产生特征提取失败示语的特征提取次数(小于该值不提示) */ @@ -30,6 +45,10 @@ public class RecognizeConfiguration { * 图像质量检测阈值:适用于戴口罩且人脸识别场景 */ private float imageQualityMaskRecognizeThreshold; + /** + * 在库照片质量诊断阈值,不参与实时识别和特征注册。 + */ + private float libraryFaceQualityThreshold; /** * 识别失败重试间隔 */ @@ -86,6 +105,7 @@ public class RecognizeConfiguration { this.similarThreshold = builder.similarThreshold; this.imageQualityNoMaskRecognizeThreshold = builder.imageQualityNoMaskRecognizeThreshold; this.imageQualityMaskRecognizeThreshold = builder.imageQualityMaskRecognizeThreshold; + this.libraryFaceQualityThreshold = builder.libraryFaceQualityThreshold; this.enableLiveness = builder.enableLiveness; this.enableImageQuality = builder.enableImageQuality; this.enableFaceAreaLimit = builder.enableFaceAreaLimit; @@ -100,23 +120,25 @@ public class RecognizeConfiguration { //TODO: demo不实现所有配置,若以下项也需要进行自定义配置,可参考其他配置项实现 public static class Builder { - private int extractRetryCount = 3; - private int livenessRetryCount = 3; - private int maxDetectFaces = 3; - private int recognizeFailedRetryInterval = 0; - private int livenessFailedRetryInterval = 0; - private float similarThreshold = 0.8f; - private float imageQualityNoMaskRecognizeThreshold = 0.49f; - private float imageQualityMaskRecognizeThreshold = 0.29f; - private boolean enableLiveness = false; + private int extractRetryCount = DEFAULT_EXTRACT_RETRY_COUNT; + private int livenessRetryCount = DEFAULT_LIVENESS_RETRY_COUNT; + private int maxDetectFaces = DEFAULT_MAX_DETECT_FACES; + private int recognizeFailedRetryInterval = DEFAULT_RECOGNIZE_FAILED_RETRY_INTERVAL; + private int livenessFailedRetryInterval = DEFAULT_LIVENESS_FAILED_RETRY_INTERVAL; + private float similarThreshold = DEFAULT_SIMILAR_THRESHOLD; + private float imageQualityNoMaskRecognizeThreshold = DEFAULT_IMAGE_QUALITY_NO_MASK_THRESHOLD; + private float imageQualityMaskRecognizeThreshold = DEFAULT_IMAGE_QUALITY_MASK_THRESHOLD; + /** 在库照片质量诊断阈值,允许范围为0到1。 */ + private float libraryFaceQualityThreshold = DEFAULT_LIBRARY_FACE_QUALITY_THRESHOLD; + private boolean enableLiveness = DEFAULT_ENABLE_LIVENESS; private boolean enableFaceAreaLimit = false; - private boolean enableImageQuality = false; + private boolean enableImageQuality = DEFAULT_ENABLE_IMAGE_QUALITY; private boolean enableFaceSizeLimit = false; private boolean enableFaceMoveLimit = false; private int faceSizeLimit = 0; private int faceMoveLimit = 0; - private boolean keepMaxFace = false; - private LivenessParam livenessParam; + private boolean keepMaxFace = DEFAULT_KEEP_MAX_FACE; + private LivenessParam livenessParam = new LivenessParam(DEFAULT_RGB_LIVENESS_THRESHOLD, DEFAULT_RGB_LIVENESS_THRESHOLD); public Builder recognizeFailedRetryInterval(int val) { this.recognizeFailedRetryInterval = val; @@ -158,6 +180,17 @@ public class RecognizeConfiguration { return this; } + /** + * 设置在库照片质量诊断阈值。 + * + * @param val 0到1之间的质量分界值 + * @return 当前构建器 + */ + public Builder libraryFaceQualityThreshold(float val) { + this.libraryFaceQualityThreshold = val; + return this; + } + public Builder enableLiveness(boolean val) { this.enableLiveness = val; return this; @@ -217,10 +250,51 @@ public class RecognizeConfiguration { return imageQualityMaskRecognizeThreshold; } + /** + * 读取在库照片质量诊断阈值。 + * + * @return 0到1之间的质量分界值 + */ + public float getLibraryFaceQualityThreshold() { + return libraryFaceQualityThreshold; + } + public boolean isEnableImageQuality() { return enableImageQuality; } + /** + * 创建仅切换实时图像质量能力的运行时配置,其他现场参数保持不变。 + * + *

该副本不会写回 SharedPreferences,专供虹软质量增值模块初始化失败时 + * 降级本次识别会话,避免授权差异阻断原有特征提取和比对。

+ * + * @param enabled 本次识别会话是否执行实时图像质量检测 + * @return 保留其余识别参数的新配置 + */ + public RecognizeConfiguration withImageQualityEnabled(boolean enabled) { + return new Builder() + .extractRetryCount(extractRetryCount) + .livenessRetryCount(livenessRetryCount) + .maxDetectFaces(maxDetectFaces) + .recognizeFailedRetryInterval(recognizeFailedRetryInterval) + .livenessFailedRetryInterval(livenessFailedRetryInterval) + .similarThreshold(similarThreshold) + .imageQualityNoMaskRecognizeThreshold(imageQualityNoMaskRecognizeThreshold) + .imageQualityMaskRecognizeThreshold(imageQualityMaskRecognizeThreshold) + .libraryFaceQualityThreshold(libraryFaceQualityThreshold) + .enableLiveness(enableLiveness) + .enableImageQuality(enabled) + .enableFaceAreaLimit(enableFaceAreaLimit) + .keepMaxFace(keepMaxFace) + .livenessParam(livenessParam) + .enableFaceSizeLimit(enableFaceSizeLimit) + .enableFaceMoveLimit(enableFaceMoveLimit) + .faceSizeLimit(faceSizeLimit) + .faceMoveLimit(faceMoveLimit) + .build(); + } + public boolean isEnableFaceAreaLimit() { return enableFaceAreaLimit; } @@ -291,6 +365,7 @@ public class RecognizeConfiguration { "enableImageQuality: " + enableImageQuality + "\r\n" + "imageQualityNoMaskRecognizeThreshold: " + imageQualityNoMaskRecognizeThreshold + "\r\n" + "imageQualityMaskRecognizeThreshold: " + imageQualityMaskRecognizeThreshold + "\r\n" + + "libraryFaceQualityThreshold: " + libraryFaceQualityThreshold + "\r\n" + "enableLiveness: " + enableLiveness + "\r\n" + "livenessRetryCount: " + livenessRetryCount + "\r\n" + diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStore.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStore.java new file mode 100644 index 0000000..685cd93 --- /dev/null +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeConfigurationStore.java @@ -0,0 +1,142 @@ +package io.flutter.plugins.facediscern.tool.face.model; + +import android.content.Context; +import android.content.SharedPreferences; + +import com.arcsoft.face.LivenessParam; + +import java.util.HashMap; +import java.util.Map; + +/** + * 保存终端本机的人脸识别策略,不写入业务数据库,避免配置升级影响 Floor 与人脸共享库。 + */ +public final class RecognizeConfigurationStore { + private static final String PREFERENCES_NAME = "face_recognize_configuration"; + + private RecognizeConfigurationStore() { + } + + public static RecognizeConfiguration load(Context context) { + SharedPreferences preferences = preferences(context); + return new RecognizeConfiguration.Builder() + .enableLiveness(preferences.getBoolean("enableLiveness", RecognizeConfiguration.DEFAULT_ENABLE_LIVENESS)) + .similarThreshold(preferences.getFloat("similarThreshold", RecognizeConfiguration.DEFAULT_SIMILAR_THRESHOLD)) + .livenessParam(new LivenessParam( + preferences.getFloat("rgbLivenessThreshold", RecognizeConfiguration.DEFAULT_RGB_LIVENESS_THRESHOLD), + RecognizeConfiguration.DEFAULT_RGB_LIVENESS_THRESHOLD)) + .enableImageQuality(preferences.getBoolean("enableImageQuality", RecognizeConfiguration.DEFAULT_ENABLE_IMAGE_QUALITY)) + .imageQualityNoMaskRecognizeThreshold(preferences.getFloat("imageQualityNoMaskRecognizeThreshold", RecognizeConfiguration.DEFAULT_IMAGE_QUALITY_NO_MASK_THRESHOLD)) + .imageQualityMaskRecognizeThreshold(preferences.getFloat("imageQualityMaskRecognizeThreshold", RecognizeConfiguration.DEFAULT_IMAGE_QUALITY_MASK_THRESHOLD)) + .libraryFaceQualityThreshold(preferences.getFloat("libraryFaceQualityThreshold", RecognizeConfiguration.DEFAULT_LIBRARY_FACE_QUALITY_THRESHOLD)) + .keepMaxFace(preferences.getBoolean("keepMaxFace", RecognizeConfiguration.DEFAULT_KEEP_MAX_FACE)) + .maxDetectFaces(preferences.getInt("maxDetectFaces", RecognizeConfiguration.DEFAULT_MAX_DETECT_FACES)) + .extractRetryCount(preferences.getInt("extractRetryCount", RecognizeConfiguration.DEFAULT_EXTRACT_RETRY_COUNT)) + .livenessRetryCount(preferences.getInt("livenessRetryCount", RecognizeConfiguration.DEFAULT_LIVENESS_RETRY_COUNT)) + .recognizeFailedRetryInterval(preferences.getInt("recognizeFailedRetryInterval", RecognizeConfiguration.DEFAULT_RECOGNIZE_FAILED_RETRY_INTERVAL)) + .livenessFailedRetryInterval(preferences.getInt("livenessFailedRetryInterval", RecognizeConfiguration.DEFAULT_LIVENESS_FAILED_RETRY_INTERVAL)) + .build(); + } + + /** + * Flutter 只传允许现场调整的白名单字段,原生再次校验范围,防止异常参数拖垮识别线程。 + */ + public static RecognizeConfiguration save(Context context, Map values) { + RecognizeConfiguration current = load(context); + boolean enableLiveness = booleanValue(values, "enableLiveness", current.isEnableLiveness()); + float similarThreshold = floatValue(values, "similarThreshold", current.getSimilarThreshold(), 0.5F, 0.99F); + float rgbLivenessThreshold = floatValue(values, "rgbLivenessThreshold", rgbLivenessThreshold(current), 0.3F, 0.9F); + boolean enableImageQuality = booleanValue(values, "enableImageQuality", current.isEnableImageQuality()); + float noMaskThreshold = floatValue(values, "imageQualityNoMaskRecognizeThreshold", current.getImageQualityNoMaskRecognizeThreshold(), 0F, 1F); + float maskThreshold = floatValue(values, "imageQualityMaskRecognizeThreshold", current.getImageQualityMaskRecognizeThreshold(), 0F, 1F); + float libraryFaceQualityThreshold = floatValue(values, "libraryFaceQualityThreshold", current.getLibraryFaceQualityThreshold(), 0F, 1F); + boolean keepMaxFace = booleanValue(values, "keepMaxFace", current.isKeepMaxFace()); + int maxDetectFaces = intValue(values, "maxDetectFaces", current.getMaxDetectFaces(), 1, 3); + int extractRetryCount = intValue(values, "extractRetryCount", current.getExtractRetryCount(), 0, 10); + int livenessRetryCount = intValue(values, "livenessRetryCount", current.getLivenessRetryCount(), 0, 10); + int recognizeRetryInterval = intValue(values, "recognizeFailedRetryInterval", current.getRecognizeFailedRetryInterval(), 0, 5000); + int livenessRetryInterval = intValue(values, "livenessFailedRetryInterval", current.getLivenessFailedRetryInterval(), 0, 5000); + + boolean saved = preferences(context).edit() + .putBoolean("enableLiveness", enableLiveness) + .putFloat("similarThreshold", similarThreshold) + .putFloat("rgbLivenessThreshold", rgbLivenessThreshold) + .putBoolean("enableImageQuality", enableImageQuality) + .putFloat("imageQualityNoMaskRecognizeThreshold", noMaskThreshold) + .putFloat("imageQualityMaskRecognizeThreshold", maskThreshold) + .putFloat("libraryFaceQualityThreshold", libraryFaceQualityThreshold) + .putBoolean("keepMaxFace", keepMaxFace) + .putInt("maxDetectFaces", maxDetectFaces) + .putInt("extractRetryCount", extractRetryCount) + .putInt("livenessRetryCount", livenessRetryCount) + .putInt("recognizeFailedRetryInterval", recognizeRetryInterval) + .putInt("livenessFailedRetryInterval", livenessRetryInterval) + .commit(); + if (!saved) throw new IllegalStateException("人脸识别配置保存失败"); + return load(context); + } + + public static RecognizeConfiguration reset(Context context) { + if (!preferences(context).edit().clear().commit()) { + throw new IllegalStateException("人脸识别配置恢复失败"); + } + return load(context); + } + + public static Map toMap(RecognizeConfiguration configuration) { + Map result = new HashMap<>(); + result.put("enableLiveness", configuration.isEnableLiveness()); + result.put("similarThreshold", configuration.getSimilarThreshold()); + result.put("rgbLivenessThreshold", rgbLivenessThreshold(configuration)); + result.put("enableImageQuality", configuration.isEnableImageQuality()); + result.put("imageQualityNoMaskRecognizeThreshold", configuration.getImageQualityNoMaskRecognizeThreshold()); + result.put("imageQualityMaskRecognizeThreshold", configuration.getImageQualityMaskRecognizeThreshold()); + result.put("libraryFaceQualityThreshold", configuration.getLibraryFaceQualityThreshold()); + result.put("keepMaxFace", configuration.isKeepMaxFace()); + result.put("maxDetectFaces", configuration.getMaxDetectFaces()); + result.put("extractRetryCount", configuration.getExtractRetryCount()); + result.put("livenessRetryCount", configuration.getLivenessRetryCount()); + result.put("recognizeFailedRetryInterval", configuration.getRecognizeFailedRetryInterval()); + result.put("livenessFailedRetryInterval", configuration.getLivenessFailedRetryInterval()); + result.put("livenessType", "RGB"); + return result; + } + + private static SharedPreferences preferences(Context context) { + return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE); + } + + private static float rgbLivenessThreshold(RecognizeConfiguration configuration) { + LivenessParam param = configuration.getLivenessParam(); + return param == null ? RecognizeConfiguration.DEFAULT_RGB_LIVENESS_THRESHOLD : param.getRgbThreshold(); + } + + private static boolean booleanValue(Map values, String key, boolean fallback) { + Object value = values == null ? null : values.get(key); + if (value == null) return fallback; + if (!(value instanceof Boolean)) throw new IllegalArgumentException(key + " 必须为布尔值"); + return (Boolean) value; + } + + private static float floatValue(Map values, String key, float fallback, float min, float max) { + Object value = values == null ? null : values.get(key); + if (value == null) return fallback; + if (!(value instanceof Number)) throw new IllegalArgumentException(key + " 必须为数字"); + float result = ((Number) value).floatValue(); + if (Float.isNaN(result) || Float.isInfinite(result) || result < min || result > max) { + throw new IllegalArgumentException(key + " 必须在 " + min + " 到 " + max + " 之间"); + } + return result; + } + + private static int intValue(Map values, String key, int fallback, int min, int max) { + Object value = values == null ? null : values.get(key); + if (value == null) return fallback; + if (!(value instanceof Number)) throw new IllegalArgumentException(key + " 必须为整数"); + int result = ((Number) value).intValue(); + if (result < min || result > max) { + throw new IllegalArgumentException(key + " 必须在 " + min + " 到 " + max + " 之间"); + } + return result; + } +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeInfo.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeInfo.java index 9350b37..bba4d54 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeInfo.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/model/RecognizeInfo.java @@ -19,6 +19,8 @@ public class RecognizeInfo { * 用于存储活体值 */ private int liveness = LivenessInfo.UNKNOWN; + /** 最近一次人脸库比对相似度;尚未完成比对时为空。 */ + private volatile Float similarity; /** * 用于存储活体检测出错重试次数 */ @@ -52,6 +54,14 @@ public class RecognizeInfo { return liveness; } + public Float getSimilarity() { + return similarity; + } + + public void setSimilarity(Float similarity) { + this.similarity = similarity; + } + public int increaseAndGetLivenessErrorRetryCount() { return ++livenessErrorRetryCount; } @@ -83,4 +93,4 @@ public class RecognizeInfo { public int getLivenessErrorRetryCount() { return livenessErrorRetryCount; } -} \ No newline at end of file +} diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java index 04b092d..12f6dd9 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/tool/face/servcie/FaceServer.java @@ -11,6 +11,7 @@ import com.arcsoft.face.FaceEngine; import com.arcsoft.face.FaceFeature; import com.arcsoft.face.FaceFeatureInfo; import com.arcsoft.face.FaceInfo; +import com.arcsoft.face.ImageQualitySimilar; import com.arcsoft.face.MaskInfo; import com.arcsoft.face.SearchResult; import com.arcsoft.face.enums.DetectFaceOrientPriority; @@ -25,8 +26,13 @@ import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.util.ArrayList; +import java.util.HashMap; +import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.HashSet; import io.flutter.plugins.facediscern.tool.face.model.CompareResult; import io.flutter.plugins.facediscern.tool.face.model.FaceEntity; @@ -38,7 +44,10 @@ public class FaceServer { private static final String TAG = "FaceServer"; private static FaceEngine faceEngine = null; private static volatile FaceServer faceServer = null; - private List faceRegisterInfoList = new ArrayList<>(); + private final Map faceRegisterInfoMap = new HashMap<>(); + private int engineInitCode = -99; + private int imageQualityInitCode = -99; + private boolean imageQualityReady; private String imageRootPath; /** * 最大注册人脸数 @@ -48,19 +57,57 @@ public class FaceServer { public synchronized void init(Context context) { if (faceEngine == null && context != null) { faceEngine = new FaceEngine(); - int engineCode = faceEngine.init( + int requestedCode = faceEngine.init( context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, 1, - FaceEngine.ASF_FACE_RECOGNITION | FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_MASK_DETECT + FaceEngine.ASF_FACE_RECOGNITION | FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_MASK_DETECT | FaceEngine.ASF_IMAGEQUALITY ); - if (engineCode != ErrorInfo.MOK) { - Log.e(TAG, "人脸引擎初始化失败code" + engineCode); + imageQualityInitCode = requestedCode; + if (requestedCode == ErrorInfo.MOK) { + engineInitCode = requestedCode; + imageQualityReady = true; + } else { + Log.w(TAG, "图像质量模块不可用,回退基础人脸引擎code" + requestedCode); + faceEngine.unInit(); + faceEngine = null; + faceEngine = new FaceEngine(); + engineInitCode = faceEngine.init( + context, + DetectMode.ASF_DETECT_MODE_IMAGE, + DetectFaceOrientPriority.ASF_OP_ALL_OUT, + 1, + FaceEngine.ASF_FACE_RECOGNITION | FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_MASK_DETECT + ); + imageQualityReady = false; + if (engineInitCode != ErrorInfo.MOK) { + Log.e(TAG, "人脸引擎初始化失败code" + engineInitCode); + faceEngine.unInit(); + faceEngine = null; + } } } } + public synchronized int getEngineInitCode() { + return engineInitCode; + } + + public synchronized boolean isEngineReady() { + return faceEngine != null && engineInitCode == ErrorInfo.MOK; + } + + /** 返回当前授权是否包含虹软图像质量模块。 */ + public synchronized boolean isImageQualityReady() { + return imageQualityReady; + } + + /** 返回图像质量模块初始化返回码,用于终端诊断授权状态。 */ + public synchronized int getImageQualityInitCode() { + return imageQualityInitCode; + } + public static FaceServer getInstance() { if (faceServer == null) { synchronized (FaceServer.class) { @@ -76,58 +123,142 @@ public class FaceServer { * 销毁 */ public synchronized void release() { - if (faceRegisterInfoList != null) { - faceRegisterInfoList.clear(); - faceRegisterInfoList = null; - } + faceRegisterInfoMap.clear(); if (faceEngine != null) { synchronized (faceEngine) { faceEngine.unInit(); } faceEngine = null; } + engineInitCode = -99; + imageQualityInitCode = -99; + imageQualityReady = false; faceServer = null; } - public int registerFaceFeatureInfoListFromDb(List faceEntityList) { + /** + * 全量重建使用:先清空旧内存库,再按调用方传入的数据重新装载。 + */ + public synchronized int registerFaceFeatureInfoListFromDb(List faceEntityList) { if (faceEngine == null) return -99; + faceEngine.removeFaceFeature(-1); + faceRegisterInfoMap.clear(); + return registerFaceFeatureInfoBatchFromDb(faceEntityList); + } + + /** + * 只追加当前批次,不清空其他人脸;用于分批全量装载和单用户增量替换。 + */ + public synchronized int registerFaceFeatureInfoBatchFromDb(List faceEntityList) { + if (faceEngine == null || faceEntityList == null || faceEntityList.isEmpty()) return -99; List faceFeatureInfoList = new ArrayList<>(); for (FaceEntity faceEntity : faceEntityList) { FaceFeatureInfo faceFeatureInfo = new FaceFeatureInfo((int) faceEntity.getFaceId(), faceEntity.getFaceModel()); faceFeatureInfoList.add(faceFeatureInfo); } - //首先清除FaceEngine中所有人脸数据,再添加新的人脸数据 - faceEngine.removeFaceFeature(-1); int code = faceEngine.registerFaceFeature(faceFeatureInfoList); if (code == ErrorInfo.MOK) { - faceRegisterInfoList.addAll(faceEntityList); + for (FaceEntity faceEntity : faceEntityList) { + faceRegisterInfoMap.put(faceEntity.getFaceId(), faceEntity); + } } return code; } - // 注册单个人脸数据 - public int registerFaceFeatureInfoFromDb(FaceEntity faceEntity) { - if (faceEntity != null && faceEngine != null) { - FaceFeatureInfo faceFeatureInfo = new FaceFeatureInfo(faceEntity.getFaceId(), faceEntity.getFaceModel()); - int code = faceEngine.registerFaceFeature(faceFeatureInfo); - if (code == ErrorInfo.MOK) { - faceEntity.setFaceModel(faceFeatureInfo.getFeatureData()); - // 查询一下确保入库成功 - FaceFeatureInfo afterDetail = getFaceFeature(faceEntity.getFaceId()); - if (Objects.nonNull(afterDetail)) { - long count = faceRegisterInfoList.stream().filter(v -> Objects.equals(v.getFaceId(), faceEntity.getFaceId())).count(); - if (count == 0) { - faceRegisterInfoList.add(faceEntity); - } - return code; - } - } + /** + * 在同一虹软引擎互斥边界内原子替换单个搜索标识的人脸特征。 + * + *

替换前复制旧特征和业务元数据;新特征注册失败或 SDK 抛异常时恢复旧特征, + * 避免坏图导致原本可识别的人脸永久丢失。

+ * + * @param faceEntity 包含搜索标识、用户元数据和新特征的目标人脸 + * @return 虹软返回码,插件异常返回内部错误码 {@code -99} + */ + public synchronized int replaceFaceFeature(FaceEntity faceEntity) { + if (faceEntity == null || faceEngine == null || faceEntity.getFaceId() == null || faceEntity.getFaceModel() == null) { + return -99; } - return -99; + int searchId = faceEntity.getFaceId(); + FaceEntity oldEntity = copyFaceEntity(faceRegisterInfoMap.get(searchId)); + try { + FaceFeatureInfo oldFeature = getFaceFeature(searchId); + if (oldEntity == null && oldFeature != null) { + oldEntity = new FaceEntity(searchId, oldFeature.getFeatureData()); + } + faceEngine.removeFaceFeature(searchId); + faceRegisterInfoMap.remove(searchId); + FaceFeatureInfo newFeature = new FaceFeatureInfo(searchId, faceEntity.getFaceModel()); + int code = faceEngine.registerFaceFeature(Collections.singletonList(newFeature)); + if (code == ErrorInfo.MOK && getFaceFeature(searchId) != null) { + faceRegisterInfoMap.put(searchId, faceEntity); + return code; + } + restoreFaceFeature(searchId, oldEntity); + return code == ErrorInfo.MOK ? -99 : code; + } catch (Exception exception) { + Log.e(TAG, "替换人脸特征异常searchId:" + searchId + ";error:" + exception.getMessage(), exception); + restoreFaceFeature(searchId, oldEntity); + return -99; + } + } + + /** + * 返回当前虹软内存库已登记的搜索标识快照,用于集合一致性判断。 + * + * @return 与内部映射解耦的搜索标识集合 + */ + public synchronized Set getRegisteredFaceIds() { + return new HashSet<>(faceRegisterInfoMap.keySet()); + } + + /** + * 恢复单样本替换前的人脸特征;没有旧特征时保持目标搜索标识为空。 + * + * @param searchId 需要恢复的虹软搜索标识 + * @param oldEntity 替换前的特征和元数据,新增场景为空 + */ + private void restoreFaceFeature(int searchId, FaceEntity oldEntity) { + try { + faceEngine.removeFaceFeature(searchId); + faceRegisterInfoMap.remove(searchId); + if (oldEntity == null || oldEntity.getFaceModel() == null) return; + FaceFeatureInfo oldFeature = new FaceFeatureInfo(searchId, oldEntity.getFaceModel()); + if (faceEngine.registerFaceFeature(Collections.singletonList(oldFeature)) == ErrorInfo.MOK) { + faceRegisterInfoMap.put(searchId, oldEntity); + } else { + Log.e(TAG, "恢复旧人脸特征失败searchId:" + searchId); + } + } catch (Exception exception) { + Log.e(TAG, "恢复旧人脸特征异常searchId:" + searchId + ";error:" + exception.getMessage(), exception); + } + } + + /** + * 深拷贝内存库中的人脸业务元数据和特征,避免回滚快照被后续修改。 + * + * @param source 待复制的人脸记录 + * @return 独立副本,源记录为空时返回空 + */ + private FaceEntity copyFaceEntity(FaceEntity source) { + if (source == null) return null; + FaceEntity copy = new FaceEntity(); + copy.setFaceId(source.getFaceId()); + copy.setSampleId(source.getSampleId()); + copy.setUserId(source.getUserId()); + copy.setUserName(source.getUserName()); + copy.setFaceFilePath(source.getFaceFilePath()); + copy.setFaceModel(source.getFaceModel() == null ? null : source.getFaceModel().clone()); + return copy; + } + + // 注册单个人脸数据 + public synchronized int registerFaceFeatureInfoFromDb(FaceEntity faceEntity) { + // 历史单条入口也复用带旧特征回滚的替换逻辑,避免 SDK 异常后丢失原可用样本。 + return replaceFaceFeature(faceEntity); } // 从人脸数据库中搜索人脸 - public FaceFeatureInfo getFaceFeature(int searchId) { + public synchronized FaceFeatureInfo getFaceFeature(int searchId) { FaceFeatureInfo faceFeatureInfo = null; try { faceFeatureInfo = faceEngine.getFaceFeature(searchId); @@ -138,19 +269,49 @@ public class FaceServer { } // 获取人脸数量 - public int getTotalFaceCount() { + public synchronized int getTotalFaceCount() { if (faceEngine == null) return -99; return faceEngine.getFaceCount(); } // 删除人脸-1删除全部 - public int removeFace(int searchId) { + public synchronized int removeFace(int searchId) { if (faceEngine == null) return -99; - return faceEngine.removeFaceFeature(searchId); + // MQ 和每日快照都可能重复删除同一样本;内存库已不存在时直接按幂等成功处理。 + if (searchId != -1 && getFaceFeature(searchId) == null) { + faceRegisterInfoMap.remove(searchId); + return ErrorInfo.MOK; + } + int code = faceEngine.removeFaceFeature(searchId); + if (code == ErrorInfo.MOK) { + if (searchId == -1) { + faceRegisterInfoMap.clear(); + } else { + faceRegisterInfoMap.remove(searchId); + } + } + return code; + } + + /** + * 增量更新时仅移除该用户已注册的样本,避免同步单人后覆盖整个内存库。 + */ + public synchronized void removeFacesByUserId(String userId) { + if (faceEngine == null || userId == null) return; + List faceIds = new ArrayList<>(); + for (Map.Entry entry : faceRegisterInfoMap.entrySet()) { + if (Objects.equals(userId, entry.getValue().getUserId())) { + faceIds.add(entry.getKey()); + } + } + for (Integer faceId : faceIds) { + faceEngine.removeFaceFeature(faceId); + faceRegisterInfoMap.remove(faceId); + } } // 在特征库中搜索 - public CompareResult searchFaceFeature(FaceFeature faceFeature) { + public synchronized CompareResult searchFaceFeature(FaceFeature faceFeature) { if (faceFeature == null) return null; if (faceEngine == null) return null; SearchResult searchResult = null; @@ -160,8 +321,8 @@ public class FaceServer { long searchCost = System.currentTimeMillis() - searchStart; if (searchResult != null) { FaceFeatureInfo faceFeatureInfo = searchResult.getFaceFeatureInfo(); - FaceEntity entity = faceRegisterInfoList.stream().filter(v -> Objects.equals(v.getFaceId(), faceFeatureInfo.getSearchId())).findFirst().get(); - if (Objects.isNull(entity)) { + FaceEntity entity = faceRegisterInfoMap.get(faceFeatureInfo.getSearchId()); + if (entity == null) { entity = new FaceEntity(faceFeatureInfo.getSearchId(), faceFeatureInfo.getFeatureData()); } Log.i(TAG, "查询人脸总耗时:" + (System.currentTimeMillis() - searchStart) + "ms;username:" + entity.getUserName() + ";faceId:" + entity.getFaceId() + ";"); @@ -174,10 +335,23 @@ public class FaceServer { } - public byte[] registerBgr24(Context context, byte[] bgr24, int width, int height, String name) { + /** 保持旧调用方返回特征字节的兼容入口。 */ + public synchronized byte[] registerBgr24(Context context, byte[] bgr24, int width, int height, String name) { + return registerBgr24WithQuality(context, bgr24, width, height, name).featureData; + } + + /** + * 在统一解码结果上同时执行图像质量检测和虹软特征提取。 + * + *

质量模块不可用或检测失败时仅记录failed状态,仍继续原有特征提取,兼容免费版授权。

+ */ + public synchronized QualityFeatureResult registerBgr24WithQuality(Context context, byte[] bgr24, int width, int height, String name) { + QualityFeatureResult qualityResult = new QualityFeatureResult(); if (faceEngine == null || context == null || bgr24 == null || width % 4 != 0 || bgr24.length != width * height * 3) { Log.e(TAG, "registerBgr24: invalid params"); - return null; + qualityResult.qualityStatus = "failed"; + qualityResult.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM; + return qualityResult; } //人脸检测 List faceInfoList = new ArrayList<>(); @@ -186,6 +360,25 @@ public class FaceServer { code = faceEngine.detectFaces(bgr24, width, height, FaceEngine.CP_PAF_BGR24, faceInfoList); } if (code == ErrorInfo.MOK && !faceInfoList.isEmpty()) { + if (imageQualityReady) { + ImageQualitySimilar qualitySimilar = new ImageQualitySimilar(); + int qualityCode; + synchronized (faceEngine) { + qualityCode = faceEngine.imageQualityDetect( + bgr24, width, height, FaceEngine.CP_PAF_BGR24, + faceInfoList.get(0), MaskInfo.NOT_WORN, qualitySimilar); + } + if (qualityCode == ErrorInfo.MOK) { + qualityResult.qualityStatus = "detected"; + qualityResult.qualityScore = (double) qualitySimilar.getScore(); + } else { + qualityResult.qualityStatus = "failed"; + qualityResult.qualityErrorCode = qualityCode; + } + } else { + qualityResult.qualityStatus = "failed"; + qualityResult.qualityErrorCode = imageQualityInitCode; + } code = faceEngine.process(bgr24, width, height, FaceEngine.CP_PAF_BGR24, faceInfoList, FaceEngine.ASF_MASK_DETECT); if (code == ErrorInfo.MOK) { @@ -198,16 +391,18 @@ public class FaceServer { * 注册照要求不戴口罩 */ Log.e(TAG, "registerBgr24: maskInfo is worn"); - return null; + return qualityResult; } if (isMask == MaskInfo.UNKNOWN) { Log.e(TAG, "registerBgr24: maskInfo is unknown"); - return null; + return qualityResult; } } } else { Log.e(TAG, "registerBgr24 mask process fail res=" + code); - return null; + qualityResult.qualityStatus = "failed"; + qualityResult.qualityErrorCode = code; + return qualityResult; } FaceFeature faceFeature = new FaceFeature(); /* @@ -224,7 +419,9 @@ public class FaceServer { Rect cropRect = getBestRect(width, height, faceInfoList.get(0).getRect()); if (cropRect == null) { Log.e(TAG, "registerBgr24: cropRect is null"); - return null; + qualityResult.qualityStatus = "failed"; + qualityResult.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM; + return qualityResult; } cropRect.left &= ~3; cropRect.top &= ~3; @@ -233,25 +430,83 @@ public class FaceServer { String imgPath = getImagePath(userName, context); // 创建一个头像的Bitmap,存放旋转结果图 Bitmap headBmp = getHeadImage(bgr24, width, height, faceInfoList.get(0).getOrient(), cropRect, ArcSoftImageFormat.BGR24); - try { - FileOutputStream fos = new FileOutputStream(imgPath); + try (FileOutputStream fos = new FileOutputStream(imgPath)) { headBmp.compress(Bitmap.CompressFormat.JPEG, 100, fos); - fos.close(); } catch (IOException e) { - e.printStackTrace(); - return null; + Log.e(TAG, "registerBgr24: save head image failed", e); + return qualityResult; + } finally { + if (!headBmp.isRecycled()) headBmp.recycle(); } - return faceFeature.getFeatureData(); + qualityResult.featureData = faceFeature.getFeatureData(); + return qualityResult; } else { Log.e(TAG, "registerBgr24: extract face feature failed, code is " + code); - return null; + return qualityResult; } } else { Log.e(TAG, "registerBgr24: no face detected, code is " + code); - return null; + qualityResult.qualityStatus = "failed"; + qualityResult.qualityErrorCode = code == ErrorInfo.MOK + ? ErrorInfo.MERR_FSDK_FACEFEATURE_NO_FACE + : code; + return qualityResult; } } + /** + * 仅检测BGR24在库照片的质量,不提取或写入人脸特征。 + * + * @param bgr24 BGR24图片数据 + * @param width 图片宽度 + * @param height 图片高度 + * @return detected或failed质量结果 + */ + public synchronized QualityFeatureResult detectBgr24Quality(byte[] bgr24, int width, int height) { + QualityFeatureResult result = new QualityFeatureResult(); + if (faceEngine == null || bgr24 == null || width % 4 != 0 || bgr24.length != width * height * 3) { + result.qualityStatus = "failed"; + result.qualityErrorCode = ErrorInfo.MERR_INVALID_PARAM; + return result; + } + List faceInfoList = new ArrayList<>(); + int detectCode = faceEngine.detectFaces(bgr24, width, height, FaceEngine.CP_PAF_BGR24, faceInfoList); + if (detectCode != ErrorInfo.MOK || faceInfoList.isEmpty()) { + result.qualityStatus = "failed"; + result.qualityErrorCode = detectCode == ErrorInfo.MOK ? ErrorInfo.MERR_FSDK_FACEFEATURE_NO_FACE : detectCode; + return result; + } + if (!imageQualityReady) { + result.qualityStatus = "failed"; + result.qualityErrorCode = imageQualityInitCode; + return result; + } + ImageQualitySimilar qualitySimilar = new ImageQualitySimilar(); + int qualityCode = faceEngine.imageQualityDetect( + bgr24, width, height, FaceEngine.CP_PAF_BGR24, + faceInfoList.get(0), MaskInfo.NOT_WORN, qualitySimilar); + if (qualityCode == ErrorInfo.MOK) { + result.qualityStatus = "detected"; + result.qualityScore = (double) qualitySimilar.getScore(); + } else { + result.qualityStatus = "failed"; + result.qualityErrorCode = qualityCode; + } + return result; + } + + /** 原生图片特征与质量诊断的组合结果。 */ + public static class QualityFeatureResult { + /** 注册特征字节,提取失败时为空。 */ + public byte[] featureData; + /** 质量状态,稳定值为detected或failed。 */ + public String qualityStatus = "pending"; + /** 检测成功时的质量分。 */ + public Double qualityScore; + /** 检测失败时的错误码。 */ + public Integer qualityErrorCode; + } + private String getImagePath(String name, Context context) { if (imageRootPath == null) { imageRootPath = getImageDir(context); diff --git a/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java b/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java index 879bcdc..6817f69 100644 --- a/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java +++ b/android/app/src/main/java/io/flutter/plugins/facediscern/ui/viewmodel/FaceDiscernViewModel.java @@ -18,6 +18,7 @@ import com.arcsoft.face.FaceInfo; import com.arcsoft.face.GenderInfo; import com.arcsoft.face.LivenessInfo; import com.arcsoft.face.LivenessParam; +import com.arcsoft.face.ErrorInfo; import com.arcsoft.face.enums.DetectFaceOrientPriority; import com.arcsoft.face.enums.DetectMode; import com.google.gson.Gson; @@ -35,6 +36,8 @@ import io.flutter.plugins.facediscern.tool.face.model.CompareResult; import io.flutter.plugins.facediscern.tool.face.model.FaceEntity; import io.flutter.plugins.facediscern.tool.face.model.FacePreviewInfo; import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfiguration; +import io.flutter.plugins.facediscern.tool.face.model.RecognizeConfigurationStore; +import io.flutter.plugins.facediscern.tool.face.model.RecognitionFrameSnapshot; import io.flutter.plugins.facediscern.tool.face.widget.FaceRectView; import io.flutter.plugins.facediscern.ui.FaceDiscern; @@ -47,6 +50,12 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback private final String TAG = "FaceDiscernViewModel"; + /** + * 仅识别最大脸时的预览检测上限。 + * 原生配置允许的上限为 3,足以让收银员看清排队场景中的目标选择,同时只让一张脸进入识别线程。 + */ + private static final int KEEP_MAX_FACE_PREVIEW_LIMIT = 3; + // 相机预览的分辨率 private Camera.Size previewSize; @@ -60,10 +69,12 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback private FaceEngine maskEngine; // IMAGE模式活体检测引擎,用于预览帧人脸活体检测 private FaceEngine flEngine; - // 各个引擎初始化的错误码 - private MutableLiveData ftInitCode = new MutableLiveData<>(); - private MutableLiveData frInitCode = new MutableLiveData<>(); - private MutableLiveData flInitCode = new MutableLiveData<>(); + // 各个引擎初始化的错误码。虹软 init 是同步调用,不能通过 LiveData.postValue + // 异步保存后立刻读取,否则主线程消息尚未分发时会把成功初始化误判为失败。 + private int ftInitCode = Integer.MIN_VALUE; + private int maskInitCode = Integer.MIN_VALUE; + private int frInitCode = Integer.MIN_VALUE; + private int flInitCode = Integer.MIN_VALUE; /// ****************** end 引擎 end ****************** @@ -101,8 +112,31 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback private HashMap faceDiscern = null; + private RecognitionListener recognitionListener; + + private RecognizeCallback faceHelperCallback = this; + private MutableLiveData recognizeConfiguration = new MutableLiveData<>(); + /** + * 新的共享会话不再依赖 Activity 广播,由进程级 Manager 直接接收识别结果。 + * 旧 Activity 不设置该监听时仍保持原有广播行为,避免破坏历史入口。 + */ + public interface RecognitionListener { + /** + * 返回当前会话唯一识别结果及实际成功帧,Manager 会在代次校验后生成现场证据。 + * + * @param result 不含原始帧的识别用户与相似度结果 + * @param generation 识别会话代次,旧 Activity 兼容链路使用 0 + * @param frameSnapshot 实际参与成功比对的帧;旧 Activity 兼容链路允许为空 + */ + void onRecognized(HashMap result, long generation, RecognitionFrameSnapshot frameSnapshot); + } + + public void setRecognitionListener(RecognitionListener recognitionListener) { + this.recognitionListener = recognitionListener; + } + public void setRgbFaceRectTransformer(FaceRectTransformer rgbFaceRectTransformer) { faceHelper.setRgbFaceRectTransformer(rgbFaceRectTransformer); } @@ -119,46 +153,133 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback registerStatus = status; } - // 初始化引擎 + /** + * 读取本机识别配置并初始化虹软检测、口罩、特征和可选活体引擎。 + * 仅识别最大脸时扩大检测上限只服务预览标框,不会扩大实际参与比对的人脸数量。 + * + * @param context 应用上下文,用于初始化虹软引擎和读取本机 SharedPreferences 配置 + */ public void init(Context context) { setContext(context); + ftInitCode = Integer.MIN_VALUE; + maskInitCode = Integer.MIN_VALUE; + frInitCode = Integer.MIN_VALUE; + flInitCode = Integer.MIN_VALUE; boolean switchCamera = false; previewConfig = new PreviewConfig(switchCamera ? Camera.CameraInfo.CAMERA_FACING_FRONT : Camera.CameraInfo.CAMERA_FACING_BACK, switchCamera ? Camera.CameraInfo.CAMERA_FACING_BACK : Camera.CameraInfo.CAMERA_FACING_FRONT, 0, 0); - // 填入在设置界面设置好的配置信息 - boolean enableLive = false; - boolean enableFaceQualityDetect = true; - boolean enableFaceMoveLimit = false; - boolean enableFaceSizeLimit = false; - int faceSizeLimit = 160; - int faceMoveLimit = 20; - int maxDetectFaces = 1; - boolean keepMaxFace = true; - float similarThreshold = 0.8F; - float imageQualityNoMaskRecognizeThreshold = 0.49F; - float imageQualityMaskRecognizeThreshold = 0.29F; - RecognizeConfiguration configuration = new RecognizeConfiguration.Builder().enableFaceMoveLimit(enableFaceMoveLimit).enableFaceSizeLimit(enableFaceSizeLimit).faceSizeLimit(faceSizeLimit).faceMoveLimit(faceMoveLimit).enableLiveness(enableLive).enableImageQuality(enableFaceQualityDetect).maxDetectFaces(maxDetectFaces).keepMaxFace(keepMaxFace).similarThreshold(similarThreshold).imageQualityNoMaskRecognizeThreshold(imageQualityNoMaskRecognizeThreshold).imageQualityMaskRecognizeThreshold(imageQualityMaskRecognizeThreshold).livenessParam(new LivenessParam(0.5F, 0.5F)).build(); + // 单摄像头终端仅支持 RGB 活体,配置由设置页持久化并在每次冷启动识别引擎时读取。 + livenessType = LivenessType.RGB; + RecognizeConfiguration configuration = RecognizeConfigurationStore.load(context); + boolean enableLive = configuration.isEnableLiveness(); + boolean enableFaceQualityDetect = configuration.isEnableImageQuality(); + int maxDetectFaces = effectiveMaxDetectFaces(configuration); ftEngine = new FaceEngine(); - ftInitCode.postValue(ftEngine.init(context, DetectMode.ASF_DETECT_MODE_VIDEO, DetectFaceOrientPriority.ASF_OP_ALL_OUT, 1, FaceEngine.ASF_FACE_DETECT)); + ftInitCode = ftEngine.init(context, DetectMode.ASF_DETECT_MODE_VIDEO, DetectFaceOrientPriority.ASF_OP_ALL_OUT, maxDetectFaces, FaceEngine.ASF_FACE_DETECT); maskEngine = new FaceEngine(); - ftInitCode.postValue(maskEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, 1, FaceEngine.ASF_MASK_DETECT)); + maskInitCode = maskEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, maxDetectFaces, FaceEngine.ASF_MASK_DETECT); frEngine = new FaceEngine(); int frEngineMask = FaceEngine.ASF_FACE_RECOGNITION; if (enableFaceQualityDetect) { frEngineMask |= FaceEngine.ASF_IMAGEQUALITY; } - frInitCode.postValue(frEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_0_ONLY, 10, frEngineMask)); + frInitCode = frEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_0_ONLY, maxDetectFaces, frEngineMask); + if (enableFaceQualityDetect && frInitCode != ErrorInfo.MOK) { + int qualityInitCode = frInitCode; + Log.w(TAG, "实时图像质量模块不可用,回退基础识别引擎code=" + qualityInitCode); + frEngine.unInit(); + frEngine = new FaceEngine(); + frInitCode = frEngine.init( + context, + DetectMode.ASF_DETECT_MODE_IMAGE, + DetectFaceOrientPriority.ASF_OP_0_ONLY, + maxDetectFaces, + FaceEngine.ASF_FACE_RECOGNITION); + if (frInitCode == ErrorInfo.MOK) { + // 只关闭本次会话的质量判断,保留管理员配置,授权恢复后下次会重新尝试。 + configuration = configuration.withImageQualityEnabled(false); + } + } //启用活体检测时,才初始化活体引擎 if (enableLive) { flEngine = new FaceEngine(); int flEngineMask = (livenessType == LivenessType.RGB ? FaceEngine.ASF_LIVENESS : (FaceEngine.ASF_IR_LIVENESS | FaceEngine.ASF_FACE_DETECT)); - flInitCode.postValue(flEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, 10, flEngineMask)); - LivenessParam livenessParam = new LivenessParam(0.5F, 0.5F); + flInitCode = flEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE, DetectFaceOrientPriority.ASF_OP_ALL_OUT, maxDetectFaces, flEngineMask); + LivenessParam livenessParam = configuration.getLivenessParam(); flEngine.setLivenessParam(livenessParam); } recognizeConfiguration.setValue(configuration); flutterFaceCallbackIntent = new Intent(FaceBrodConstant.FACE_GLOBAL_INTENT); + Log.i(TAG, "虹软引擎初始化结果 " + getEngineInitSummary()); + } + + public boolean isEngineReady() { + RecognizeConfiguration configuration = recognizeConfiguration.getValue(); + return ftInitCode == ErrorInfo.MOK + && maskInitCode == ErrorInfo.MOK + && frInitCode == ErrorInfo.MOK + && (configuration == null || !configuration.isEnableLiveness() + || flInitCode == ErrorInfo.MOK); + } + + public String getEngineInitSummary() { + RecognizeConfiguration configuration = recognizeConfiguration.getValue(); + boolean livenessEnabled = configuration != null && configuration.isEnableLiveness(); + return "ft=" + ftInitCode + + ";mask=" + maskInitCode + + ";fr=" + frInitCode + + ";fl=" + (livenessEnabled ? flInitCode : "disabled") + + ";maxFaces=" + (configuration == null ? "unknown" : effectiveMaxDetectFaces(configuration)) + + ";imageQuality=" + (configuration == null ? "unknown" : configuration.isEnableImageQuality()); + } + + public boolean isLivenessEnabled() { + RecognizeConfiguration configuration = recognizeConfiguration.getValue(); + return configuration != null && configuration.isEnableLiveness(); + } + + public float getSimilarityThreshold() { + RecognizeConfiguration configuration = recognizeConfiguration.getValue(); + return configuration == null ? RecognizeConfiguration.DEFAULT_SIMILAR_THRESHOLD : configuration.getSimilarThreshold(); + } + + /** + * 返回当前引擎是否启用“仅识别最大人脸”,供 Flutter 会话事件保持与原生识别策略一致。 + * + * @return 当前配置开启时返回 true;引擎尚未完成配置加载时返回默认值 + */ + public boolean isKeepMaxFaceEnabled() { + RecognizeConfiguration configuration = recognizeConfiguration.getValue(); + return configuration == null + ? RecognizeConfiguration.DEFAULT_KEEP_MAX_FACE + : configuration.isKeepMaxFace(); + } + + /** + * 解析虹软检测引擎的人脸上限。 + * 开启最大脸模式时必须先检测到多张脸才能比较面积,因此固定提升至配置允许的三张上限。 + * + * @param configuration 当前本机识别配置 + * @return 传给虹软各引擎的单帧最大检测数量 + */ + private int effectiveMaxDetectFaces(RecognizeConfiguration configuration) { + if (configuration.isKeepMaxFace()) { + return Math.max(configuration.getMaxDetectFaces(), KEEP_MAX_FACE_PREVIEW_LIMIT); + } + return configuration.getMaxDetectFaces(); + } + + public Float getFaceSimilarity(int trackId) { + return faceHelper == null ? null : faceHelper.getSimilarity(trackId); + } + + public Integer getFaceLiveness(int trackId) { + return faceHelper == null ? null : faceHelper.getLiveness(trackId); + } + + public Integer getFaceRecognizeStatus(int trackId) { + return faceHelper == null ? null : faceHelper.getRecognizeStatus(trackId); } private void initFaceHelper(Camera.Size lastPreviewSize) { @@ -171,8 +292,8 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback } int horizontalOffset = 0; int verticalOffset = 0; - int maxDetectFaceNum = 1; - faceHelper = new FaceHelper.Builder().ftEngine(ftEngine).maskEngine(maskEngine).frEngine(frEngine).flEngine(flEngine).frQueueSize(maxDetectFaceNum).flQueueSize(maxDetectFaceNum).previewSize(previewSize).recognizeCallback(this).recognizeConfiguration(recognizeConfiguration.getValue()).trackedFaceCount(trackedFaceCount == null ? 0 : trackedFaceCount).dualCameraFaceInfoTransformer(faceInfo -> { + int maxDetectFaceNum = recognizeConfiguration.getValue().getMaxDetectFaces(); + faceHelper = new FaceHelper.Builder().ftEngine(ftEngine).maskEngine(maskEngine).frEngine(frEngine).flEngine(flEngine).frQueueSize(maxDetectFaceNum).flQueueSize(maxDetectFaceNum).previewSize(previewSize).recognizeCallback(faceHelperCallback).recognizeConfiguration(recognizeConfiguration.getValue()).trackedFaceCount(trackedFaceCount == null ? 0 : trackedFaceCount).dualCameraFaceInfoTransformer(faceInfo -> { FaceInfo irFaceInfo = new FaceInfo(faceInfo); irFaceInfo.getRect().offset(horizontalOffset, verticalOffset); return irFaceInfo; @@ -189,6 +310,17 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback initFaceHelper(lastPreviewSize); } + /** + * 每个业务会话复用 FaceHelper 与 ArcSoft FaceEngine,只重置跟踪状态并切换代次回调。 + * 这样连续排队无需重建线程池,上一位顾客的异步提取晚到时也不会串入下一笔订单。 + */ + public void beginSession(long generation) { + faceDiscern = null; + if (faceHelper == null) throw new IllegalStateException("人脸处理器尚未初始化"); + faceHelperCallback = new SessionRecognizeCallback(generation); + faceHelper.beginSession(generation, faceHelperCallback); + } + public Point loadPreviewSize() { String[] size = new String[]{"1280", "720"}; return new Point(Integer.parseInt(size[0]), Integer.parseInt(size[1])); @@ -251,8 +383,36 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback public void destroy() { if (faceHelper != null) { faceHelper.release(); + faceHelper = null; } - // FaceServer.getInstance().release(); + if (ftEngine != null) { + synchronized (ftEngine) { + ftEngine.unInit(); + } + ftEngine = null; + } + if (maskEngine != null) { + synchronized (maskEngine) { + maskEngine.unInit(); + } + maskEngine = null; + } + if (frEngine != null) { + synchronized (frEngine) { + frEngine.unInit(); + } + frEngine = null; + } + if (flEngine != null) { + synchronized (flEngine) { + flEngine.unInit(); + } + flEngine = null; + } + faceDiscern = null; + recognitionListener = null; + faceHelperCallback = this; + // FaceServer 是跨会话的内存特征库,热会话关闭时不能释放,否则下一位顾客会重新装载全库。 } public void stop() { @@ -263,7 +423,8 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback // 识别结果回调 @Override - public void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass) { + public void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass, + RecognitionFrameSnapshot frameSnapshot) { try { if (!similarPass) return; HashMap json = new HashMap<>(); @@ -292,6 +453,12 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback flutterFaceCallbackIntent = new Intent(FaceBrodConstant.FACE_GLOBAL_INTENT); } Log.i(TAG, "人脸识别成功json:" + new Gson().toJson(faceDiscern)); + if (recognitionListener != null) { + HashMap result = new HashMap<>(faceDiscern); + faceDiscern = null; + recognitionListener.onRecognized(result, 0L, null); + return; + } flutterFaceCallbackIntent.putExtra("json", new Gson().toJson(faceDiscern)); context.sendBroadcast(flutterFaceCallbackIntent); faceDiscern = null; @@ -307,4 +474,52 @@ public class FaceDiscernViewModel extends ViewModel implements RecognizeCallback Log.e(TAG, "提示文字变更的回调,错误error:" + e.getMessage(), e); } } + + private HashMap buildRecognitionResult(CompareResult compareResult) { + HashMap json = new HashMap<>(); + json.put("similar", compareResult.getSimilar()); + json.put("cost", compareResult.getCost()); + if (Objects.nonNull(compareResult.getFaceEntity())) { + FaceEntity face = compareResult.getFaceEntity(); + json.put("faceId", face.getFaceId()); + json.put("sampleId", face.getSampleId()); + json.put("userId", face.getUserId()); + json.put("userName", face.getUserName()); + } + return json; + } + + /** 会话专用回调只保存本代识别结果,避免共享字段被旧异步任务覆盖。 */ + private final class SessionRecognizeCallback implements RecognizeCallback { + /** 创建该回调时绑定的会话代次,旧代异步结果不得进入新会话。 */ + private final long generation; + + /** 当前代已经通过阈值的人脸用户和相似度结果,通知成功后立即清空。 */ + private HashMap result; + + /** 创建只服务指定会话代次的回调。 */ + private SessionRecognizeCallback(long generation) { + this.generation = generation; + } + + @Override + public void onRecognized(CompareResult compareResult, Integer liveness, boolean similarPass, + RecognitionFrameSnapshot frameSnapshot) { + if (!similarPass) return; + result = buildRecognitionResult(compareResult); + pendingFrameSnapshot = frameSnapshot; + } + + @Override + public void onNoticeChanged(String notice) { + if (!Objects.equals(notice, "") || result == null || recognitionListener == null) return; + HashMap current = new HashMap<>(result); + result = null; + recognitionListener.onRecognized(current, generation, pendingFrameSnapshot); + pendingFrameSnapshot = null; + } + + /** 与 result 同一次特征提取对应的成功帧,转交 Manager 后立即释放引用。 */ + private RecognitionFrameSnapshot pendingFrameSnapshot; + } } diff --git a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/ApkUpdateManager.kt b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/ApkUpdateManager.kt new file mode 100644 index 0000000..ea69e3e --- /dev/null +++ b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/ApkUpdateManager.kt @@ -0,0 +1,194 @@ +package com.example.pj_supermarket_cloud + +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import java.io.File +import java.security.MessageDigest + +/** + * 校验并通过 Root 静默覆盖安装当前应用 APK。 + * + * 安装前强制校验包名、签名和版本码,Root 仅用于执行 `pm install -r`,不会卸载重装、 + * 绕过签名或清理应用数据,从而保留本地数据库与 Device Owner 状态。 + */ +object ApkUpdateManager { + /** 待更新状态使用的原生偏好文件,应用替换后仍随数据目录保留。 */ + private const val PREFERENCES_NAME = "apk_update_runtime" + + /** 是否存在等待新版本启动确认的更新。 */ + private const val PENDING_KEY = "pending" + + /** 后台目标版本码。 */ + private const val EXPECTED_VERSION_CODE_KEY = "expected_version_code" + + /** 下载到应用私有目录的 APK 绝对路径。 */ + private const val APK_PATH_KEY = "apk_path" + + /** 安装命令最长允许 3 分钟,超时后按失败处理。 */ + private const val INSTALL_TIMEOUT_MILLIS = 180_000L + + /** + * 校验 APK 是否可安全覆盖当前应用。 + * + * @param context 当前应用上下文 + * @param apkPath 应用私有目录中的 APK 绝对路径 + * @param expectedVersionCode 服务端声明的目标版本码 + * @return MethodChannel 可直接返回的结构化校验结果 + */ + fun validate(context: Context, apkPath: String, expectedVersionCode: Long): Map { + val file = File(apkPath) + if (!file.isFile || file.length() <= 0L) { + return failure("APK_NOT_FOUND", "安装包不存在或为空") + } + val archive = packageArchiveInfo(context, apkPath) + ?: return failure("APK_INVALID", "无法解析安装包,请重新下载") + val current = currentPackageInfo(context) + val archiveVersionCode = versionCode(archive) + val currentVersionCode = versionCode(current) + if (archive.packageName != context.packageName) { + return failure("PACKAGE_MISMATCH", "安装包包名与当前应用不一致") + } + if (archiveVersionCode != expectedVersionCode) { + return failure("VERSION_MISMATCH", "安装包版本码与后台目标版本不一致") + } + if (archiveVersionCode <= currentVersionCode) { + return failure("VERSION_NOT_HIGHER", "目标版本不高于当前版本,已拒绝重复或降级安装") + } + if (signerDigests(archive).intersect(signerDigests(current)).isEmpty()) { + return failure("SIGNATURE_MISMATCH", "安装包签名与当前应用不一致") + } + return mapOf( + "valid" to true, + "code" to "OK", + "message" to "安装包校验通过", + "packageName" to archive.packageName, + "versionCode" to archiveVersionCode, + "versionName" to archive.versionName + ) + } + + /** + * 重新校验 Root 与 APK 后执行覆盖安装;待更新状态先于命令写入,保证进程被替换时可恢复。 + */ + fun install(context: Context, apkPath: String, expectedVersionCode: Long): Map { + val validation = validate(context, apkPath, expectedVersionCode) + if (validation["valid"] != true) return validation + val root = RootCommandRunner.checkRoot() + if (root["available"] != true) { + return failure("ROOT_UNAVAILABLE", root["message"]?.toString() ?: "Root 不可用") + } + + preferences(context).edit() + .putBoolean(PENDING_KEY, true) + .putLong(EXPECTED_VERSION_CODE_KEY, expectedVersionCode) + .putString(APK_PATH_KEY, apkPath) + .commit() + val command = RootCommandRunner.execute( + "pm install -r ${RootCommandRunner.quoteShell(apkPath)}", + INSTALL_TIMEOUT_MILLIS + ) + val success = command.exitCode == 0 && command.output.contains("Success", ignoreCase = true) + if (!success) { + clearPending(context, deleteApk = true) + val message = when { + command.timedOut -> "APK 安装超时,请检查系统安装服务" + command.output.isNotBlank() -> command.output + else -> "pm install -r 执行失败(退出码 ${command.exitCode ?: -1})" + } + return failure("INSTALL_FAILED", message) + } + return mapOf( + "valid" to true, + "code" to "INSTALL_STARTED", + "message" to "安装命令已成功执行,等待新版本启动确认", + "versionCode" to expectedVersionCode + ) + } + + /** + * 应用启动时确认待更新版本并清理私有 APK;版本未提升时保留明确失败结果并终止恢复。 + */ + fun consumePending(context: Context): Map { + val prefs = preferences(context) + if (!prefs.getBoolean(PENDING_KEY, false)) { + return mapOf("pending" to false, "success" to false) + } + val expected = prefs.getLong(EXPECTED_VERSION_CODE_KEY, -1L) + val current = versionCode(currentPackageInfo(context)) + val success = expected > 0L && current >= expected + val message = if (success) { + "自动更新完成,当前 Build $current" + } else { + "自动更新未完成,当前 Build $current,目标 Build $expected" + } + clearPending(context, deleteApk = true) + return mapOf( + "pending" to true, + "success" to success, + "message" to message, + "currentVersionCode" to current, + "expectedVersionCode" to expected + ) + } + + /** 读取当前安装应用的包信息与签名证书。 */ + @Suppress("DEPRECATION") + private fun currentPackageInfo(context: Context): PackageInfo = + context.packageManager.getPackageInfo(context.packageName, signatureFlags()) + + /** 从 APK 文件解析包信息与签名证书;文件损坏或格式不支持时返回空。 */ + @Suppress("DEPRECATION") + private fun packageArchiveInfo(context: Context, path: String): PackageInfo? = + context.packageManager.getPackageArchiveInfo(path, signatureFlags()) + + /** 根据 Android 版本选择签名信息标志,兼容项目最低 Android 7。 */ + @Suppress("DEPRECATION") + private fun signatureFlags(): Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + PackageManager.GET_SIGNING_CERTIFICATES + } else { + PackageManager.GET_SIGNATURES + } + + /** 统一读取 64 位版本码,Android 9 以下回退旧整型字段。 */ + @Suppress("DEPRECATION") + private fun versionCode(info: PackageInfo): Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + info.longVersionCode + } else { + info.versionCode.toLong() + } + + /** 计算 APK 当前签名者 SHA-256 摘要集合,用交集兼容多签名包。 */ + @Suppress("DEPRECATION") + private fun signerDigests(info: PackageInfo): Set { + val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + info.signingInfo?.apkContentsSigners ?: emptyArray() + } else { + info.signatures ?: emptyArray() + } + return signatures.map { signature -> + MessageDigest.getInstance("SHA-256") + .digest(signature.toByteArray()) + .joinToString("") { byte -> "%02x".format(byte) } + }.toSet() + } + + /** 构造统一失败结构,避免 Flutter 解析不同异常文本。 */ + private fun failure(code: String, message: String): Map = mapOf( + "valid" to false, + "code" to code, + "message" to message + ) + + /** 清理待更新偏好,并按调用场景删除应用私有目录中的 APK。 */ + private fun clearPending(context: Context, deleteApk: Boolean) { + val prefs = preferences(context) + if (deleteApk) prefs.getString(APK_PATH_KEY, null)?.let { File(it).delete() } + prefs.edit().clear().commit() + } + + /** 返回只归当前应用所有的原生待更新偏好。 */ + private fun preferences(context: Context) = + context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) +} diff --git a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskBootReceiver.kt b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskBootReceiver.kt new file mode 100644 index 0000000..bd1e3d5 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskBootReceiver.kt @@ -0,0 +1,23 @@ +package com.example.pj_supermarket_cloud + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +/** + * 开机时仅在应用已经是 Device Owner 时拉起;应用包替换后始终拉起新版本,并按现有 + * Device Owner 与自动进入配置恢复 Kiosk。 + */ +class KioskBootReceiver : BroadcastReceiver() { + /** 仅处理开机完成和本应用包替换事件,并按 Device Owner 状态决定是否拉起主界面。 */ + override fun onReceive(context: Context, intent: Intent) { + val action = intent.action + if (action != Intent.ACTION_BOOT_COMPLETED && action != Intent.ACTION_MY_PACKAGE_REPLACED) return + if (action == Intent.ACTION_BOOT_COMPLETED && !KioskModeManager.prepareForBoot(context)) return + if (action == Intent.ACTION_MY_PACKAGE_REPLACED) KioskModeManager.prepareForBoot(context) + + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + context.startActivity(launchIntent) + } +} diff --git a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskDeviceAdminReceiver.kt b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskDeviceAdminReceiver.kt new file mode 100644 index 0000000..b4f9aa2 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskDeviceAdminReceiver.kt @@ -0,0 +1,10 @@ +package com.example.pj_supermarket_cloud + +import android.app.admin.DeviceAdminReceiver + +/** + * 专用收银终端的设备管理接收器。 + * + * 该接收器只作为 Device Owner 的系统入口,应用不会在普通安装状态下自行提升权限。 + */ +class KioskDeviceAdminReceiver : DeviceAdminReceiver() diff --git a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskModeManager.kt b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskModeManager.kt new file mode 100644 index 0000000..abbcc95 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/KioskModeManager.kt @@ -0,0 +1,355 @@ +package com.example.pj_supermarket_cloud + +import android.app.Activity +import android.app.ActivityManager +import android.app.admin.DevicePolicyManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.os.Build +import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat + +/** + * 管理 Android 专用设备模式。 + * + * 只有 Device Owner 才能可靠限制主页、最近任务和状态栏。普通设备管理员或沉浸式全屏 + * 都不视为 Kiosk 可用,避免界面把“看起来全屏”误报为“无法退出”。 + */ +object KioskModeManager { + /** Kiosk 原生配置使用的应用私有偏好文件。 */ + private const val PREFERENCES_NAME = "kiosk_runtime" + + /** 是否在进入 App 时自动恢复 Lock Task 的持久化键。 */ + private const val AUTO_ENTER_ENABLED_KEY = "auto_enter_enabled" + + /** Root 执行 DPM 命令的最长等待时间。 */ + private const val DPM_TIMEOUT_MILLIS = 30_000L + + /** 厂商系统栏被边缘手势唤出后重新隐藏的等待时间,避免与手势动画同帧争用。 */ + private const val SYSTEM_BAR_REHIDE_DELAY_MILLIS = 250L + + /** 返回 Flutter 设置页和心跳共用的实时 Kiosk 能力与运行状态。 */ + fun status(context: Context): Map { + val policyManager = policyManager(context) + val deviceOwner = policyManager.isDeviceOwnerApp(context.packageName) + val autoEnterEnabled = isAutoEnterEnabled(context) + return mapOf( + "deviceAdminActive" to policyManager.isAdminActive(adminComponent(context)), + "deviceOwner" to deviceOwner, + "available" to deviceOwner, + "active" to isKioskActive(context), + "lockTaskPermitted" to policyManager.isLockTaskPermitted(context.packageName), + "autoEnterEnabled" to autoEnterEnabled, + "bootAutoStartSupported" to deviceOwner + ) + } + + /** + * Activity 每次回到前台时按配置补齐白名单并进入锁定任务。管理员退出到桌面的过程 + * 不会触发新的 onPostResume;用户再次主动打开 App 时才会重新进入 Kiosk。 + */ + fun enterIfAvailable(activity: Activity) { + if (!isAutoEnterEnabled(activity) || !ensureLockTaskPolicy(activity)) return + if (!isKioskActive(activity)) { + try { + activity.startLockTask() + } catch (_: IllegalArgumentException) { + return + } catch (_: IllegalStateException) { + return + } catch (_: SecurityException) { + return + } + } + if (isKioskActive(activity)) hideSystemBars(activity) + } + + /** + * Device Owner 设备始终支持开机拉起 App;只有开启自动进入时才预先恢复 Kiosk 策略。 + */ + fun prepareForBoot(context: Context): Boolean { + if (!policyManager(context).isDeviceOwnerApp(context.packageName)) return false + if (isAutoEnterEnabled(context)) { + return ensureLockTaskPolicy(context) + } + return try { + policyManager(context).setStatusBarDisabled(adminComponent(context), false) + true + } catch (_: Exception) { + true + } + } + + /** + * 解除当前锁定并回到系统 HOME。保留 Lock Task 白名单,自动进入配置开启时,用户 + * 下次主动打开 App 会通过 onPostResume 重新锁定。 + */ + fun exitToHome(activity: Activity): Boolean { + val policyManager = policyManager(activity) + if (!policyManager.isDeviceOwnerApp(activity.packageName)) return false + + return try { + if (isKioskActive(activity)) activity.stopLockTask() + policyManager.setStatusBarDisabled(adminComponent(activity), false) + showSystemBars(activity) + + val homeIntent = Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_HOME) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED) + } + if (homeIntent.resolveActivity(activity.packageManager) != null) { + activity.startActivity(homeIntent) + } else { + activity.moveTaskToBack(true) + } + true + } catch (_: Exception) { + // 退出失败时恢复终端策略,不能把设备留在既未锁定也未回到桌面的中间状态。 + if (isAutoEnterEnabled(activity) && ensureLockTaskPolicy(activity)) { + enterIfAvailable(activity) + } + false + } + } + + /** + * 保存 Kiosk 自动进入配置并立即同步当前 Activity 的 Lock Task 状态。 + * + * 设置页本身已经受管理员密码保护,因此关闭开关时直接退出当前 Lock Task 但仍停留在 + * App 内;开启时立即补齐策略并进入,避免持久化值与当前系统状态需要多次切换才一致。 + */ + fun setAutoEnterEnabled(activity: Activity, enabled: Boolean): Map { + if (!policyManager(activity).isDeviceOwnerApp(activity.packageName)) return status(activity) + preferences(activity).edit().putBoolean(AUTO_ENTER_ENABLED_KEY, enabled).commit() + if (enabled) { + enterIfAvailable(activity) + } else { + exitCurrentKiosk(activity) + } + if (isKioskActive(activity) != enabled) { + // 系统状态切换失败时回滚持久化值,避免界面显示关闭但设备仍处于 Lock Task。 + preferences(activity).edit().putBoolean(AUTO_ENTER_ENABLED_KEY, !enabled).commit() + } + return status(activity) + } + + /** + * 仅在用户主动触发后通过 Root 配置当前应用为 Device Owner。 + * + * 本方法不会删除系统账户、移除其他 Owner 或修改设备策略文件;Android 拒绝时按常见 + * 限制返回稳定错误编码,调用方可给管理员明确处理建议。 + */ + fun provisionDeviceOwnerWithRoot(context: Context): Map { + val policyManager = policyManager(context) + if (policyManager.isDeviceOwnerApp(context.packageName)) { + return provisionResult(true, "ALREADY_OWNER", "当前应用已经是 Device Owner") + } + val root = RootCommandRunner.checkRoot() + if (root["available"] != true) { + return provisionResult(false, "ROOT_UNAVAILABLE", root["message"]?.toString() ?: "Root 不可用") + } + val component = adminComponent(context).flattenToShortString() + val command = RootCommandRunner.execute( + "dpm set-device-owner --user 0 ${RootCommandRunner.quoteShell(component)}", + DPM_TIMEOUT_MILLIS + ) + if (policyManager.isDeviceOwnerApp(context.packageName)) { + return provisionResult(true, "OK", "Device Owner 配置成功") + } + + val output = command.output.lowercase() + val (code, message) = when { + command.timedOut -> "DPM_TIMEOUT" to "DPM 命令执行超时,请检查厂商系统策略" + "account" in output -> "ACCOUNT_EXISTS" to "设备存在系统账户,Android 不允许配置 Device Owner;请在设备初始化部署阶段处理账户后重试" + "already" in output && ("owner" in output || "profile" in output) -> + "OWNER_EXISTS" to "设备已存在其他 Device Owner 或 Profile Owner,当前应用不会自动移除它" + "system user" in output || "primary user" in output || "user 0" in output && "not" in output -> + "NON_PRIMARY_USER" to "当前不在 Android 主用户(user 0)环境,无法配置 Device Owner" + "unknown command" in output || "not found" in output || "unsupported" in output -> + "COMMAND_UNSUPPORTED" to "当前系统不支持 dpm set-device-owner 命令" + else -> "OEM_RESTRICTED" to (command.output.ifBlank { + "Android 或厂商策略拒绝配置 Device Owner,请检查设备初始化状态" + }) + } + return provisionResult(false, code, message) + } + + /** 窗口重新获得焦点时恢复 Kiosk 系统栏策略,防止临时系统界面留下可退出入口。 */ + fun reapplySystemBars(activity: Activity) { + if (isKioskActive(activity)) hideSystemBars(activity) + } + + /** 判断 Android 当前是否处于完全锁定的 Lock Task 模式。 */ + fun isKioskActive(context: Context): Boolean { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + return activityManager.lockTaskModeState == ActivityManager.LOCK_TASK_MODE_LOCKED + } + + /** 为当前 Device Owner 配置 Lock Task 白名单、系统栏策略和长按电源菜单能力。 */ + private fun ensureLockTaskPolicy(context: Context): Boolean { + val policyManager = policyManager(context) + if (!policyManager.isDeviceOwnerApp(context.packageName)) return false + return try { + policyManager.setLockTaskPackages(adminComponent(context), arrayOf(context.packageName)) + policyManager.setStatusBarDisabled(adminComponent(context), true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + policyManager.setLockTaskFeatures( + adminComponent(context), + // 仅放行系统全局操作菜单,使管理员仍能长按电源关机;主页和最近任务继续禁用。 + DevicePolicyManager.LOCK_TASK_FEATURE_GLOBAL_ACTIONS + ) + } + policyManager.isLockTaskPermitted(context.packageName) + } catch (_: Exception) { + false + } + } + + /** + * 应用 Kiosk 系统栏策略,并监听厂商 SystemUI 与输入法可见性变化。 + * + * Android 沉浸模式允许边缘手势短暂唤出系统栏;RK3568 固件不会按标准行为自动收回, + * 因此在动画结束后再次应用策略。输入法显示期间仅恢复导航栏,使系统输入法保留收起 + * 键盘入口;输入法关闭后再隐藏全部系统栏,不能为了键盘操作永久暴露系统导航。 + */ + private fun hideSystemBars(activity: Activity) { + val decorView = activity.window.decorView + var imeVisible = isInputMethodVisible(activity) + var rehidePosted = false + + /** + * 厂商 SystemUI 完成边缘手势动画后再恢复沉浸模式;同一轮 Insets 与旧版可见性 + * 回调只排队一次,并在执行前复核 Kiosk 和输入法状态,避免误隐藏键盘导航入口。 + */ + fun scheduleSystemBarRehide() { + if (rehidePosted) return + rehidePosted = true + decorView.postDelayed( + { + rehidePosted = false + if (isKioskActive(activity) && !isInputMethodVisible(activity)) { + applyKioskSystemBars(activity, false) + } + }, + SYSTEM_BAR_REHIDE_DELAY_MILLIS + ) + } + + ViewCompat.setOnApplyWindowInsetsListener(decorView) { _, insets -> + val nextImeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) + val systemBarsVisible = + insets.isVisible(WindowInsetsCompat.Type.statusBars()) || + insets.isVisible(WindowInsetsCompat.Type.navigationBars()) + if (imeVisible != nextImeVisible) { + imeVisible = nextImeVisible + decorView.post { + if (isKioskActive(activity)) { + applyKioskSystemBars(activity, nextImeVisible) + } + } + } else if (!nextImeVisible && systemBarsVisible) { + // RK3568 的 Android 11 不一定触发旧版可见性回调,必须直接监听 Insets。 + scheduleSystemBarRehide() + } + // 不消费 Insets,Flutter 仍需获得准确的 viewInsets 以完成键盘避让布局。 + insets + } + @Suppress("DEPRECATION") + decorView.setOnSystemUiVisibilityChangeListener { + if (!isKioskActive(activity)) return@setOnSystemUiVisibilityChangeListener + // 保留旧回调兼容 RK 厂商固件中的混合 SystemUI 实现。 + scheduleSystemBarRehide() + } + applyKioskSystemBars(activity, imeVisible) + ViewCompat.requestApplyInsets(decorView) + } + + /** + * 根据输入法状态应用 AndroidX Insets 与 Android 11 厂商固件仍依赖的系统栏标记。 + * + * Lock Task 继续负责禁用 Home 与最近任务;输入法可见时恢复的导航栏仅提供系统返回/ + * 收起键盘入口,状态栏始终保持隐藏。 + */ + @Suppress("DEPRECATION") + private fun applyKioskSystemBars(activity: Activity, imeVisible: Boolean) { + val decorView = activity.window.decorView + decorView.systemUiVisibility = if (imeVisible) { + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + } else { + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + } + WindowCompat.setDecorFitsSystemWindows(activity.window, false) + WindowCompat.getInsetsController(activity.window, activity.window.decorView).apply { + hide(WindowInsetsCompat.Type.statusBars()) + if (imeVisible) { + show(WindowInsetsCompat.Type.navigationBars()) + } else { + hide(WindowInsetsCompat.Type.navigationBars()) + } + systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + } + } + + /** 返回当前输入法是否实际占用窗口,空 Insets 视为键盘未显示。 */ + private fun isInputMethodVisible(activity: Activity): Boolean = + ViewCompat.getRootWindowInsets(activity.window.decorView) + ?.isVisible(WindowInsetsCompat.Type.ime()) == true + + /** 关闭当前 Lock Task 但停留在 App 内,并立即恢复可用的系统栏。 */ + private fun exitCurrentKiosk(activity: Activity) { + try { + if (isKioskActive(activity)) activity.stopLockTask() + } catch (_: Exception) { + // 后续实时状态会向 Flutter 暴露关闭失败,不能在这里伪造成功。 + } + try { + policyManager(activity).setStatusBarDisabled(adminComponent(activity), false) + } catch (_: Exception) { + // 部分厂商在 Lock Task 切换帧内暂时拒绝策略变更,系统栏恢复仍继续执行。 + } + showSystemBars(activity) + } + + /** 管理员关闭或退出 Kiosk 时移除自动重隐藏监听,并恢复正常窗口边距与系统栏。 */ + @Suppress("DEPRECATION") + private fun showSystemBars(activity: Activity) { + val decorView = activity.window.decorView + ViewCompat.setOnApplyWindowInsetsListener(decorView, null) + decorView.setOnSystemUiVisibilityChangeListener(null) + decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE + WindowCompat.setDecorFitsSystemWindows(activity.window, true) + WindowCompat.getInsetsController(activity.window, decorView) + .show(WindowInsetsCompat.Type.systemBars()) + } + + /** 返回 Manifest 已注册的设备管理员组件。 */ + private fun adminComponent(context: Context) = ComponentName(context, KioskDeviceAdminReceiver::class.java) + + /** 获取系统 DevicePolicyManager,所有 Owner 判断以其实时结果为准。 */ + private fun policyManager(context: Context) = + context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager + + /** 读取进入 App 自动恢复 Kiosk 的原生持久化配置,旧设备默认开启。 */ + private fun isAutoEnterEnabled(context: Context) = + preferences(context).getBoolean(AUTO_ENTER_ENABLED_KEY, true) + + /** 返回仅归当前应用所有的 Kiosk 偏好存储。 */ + private fun preferences(context: Context) = + context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + + /** 构造 MethodChannel 可直接返回的 DPM 配置结果。 */ + private fun provisionResult(success: Boolean, code: String, message: String): Map = + mapOf("success" to success, "code" to code, "message" to message) +} diff --git a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt index afc3f9a..4c8a358 100644 --- a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/MainActivity.kt @@ -1,28 +1,348 @@ package com.example.pj_supermarket_cloud +import android.content.Context +import android.app.ActivityManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.TrafficStats +import android.hardware.display.DisplayManager +import android.os.Build +import android.os.Debug +import android.os.Process +import android.os.StatFs +import android.os.SystemClock +import android.os.VibrationEffect +import android.os.Vibrator +import android.telephony.TelephonyManager import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugins.GeneratedPluginRegistrant import io.flutter.plugins.facediscern.flutter.FlutterFaceDiscernPlugin +import io.flutter.plugins.facediscern.flutter.FlutterFaceSubScreenPlugin +import io.flutter.plugins.facediscern.session.FaceRecognitionSessionManager +import io.flutter.plugin.common.MethodChannel +import com.rex.flutter_subscreen_plugin.FlutterSubScreenProvider +import com.rex.flutter_subscreen_plugin.FlutterSubscreenPlugin +import java.util.concurrent.Executors +/** + * 终端主屏 Flutter Activity,集中注册人脸、副屏、运行状态、Root/Kiosk 和 APK 更新通道。 + */ class MainActivity : FlutterActivity() { + /** 保存进程级运行时基准,Activity 重建时不重置 App 运行时长。 */ + companion object { + /** 进程首次加载 Activity 类时的单调时钟,用于心跳报告当前 App 运行时长。 */ + private val processStartedAtMillis = SystemClock.elapsedRealtime() + } + + /** Flutter 运行环境、Root/Kiosk 与 APK 操作共用的 MethodChannel 名称。 */ + private val runtimeChannel = "biz_xfj/runtime_environment" + + /** 主屏向独立副屏 Engine 转发轻量消息的 MethodChannel 名称。 */ + private val subScreenChannel = "screen_plugin_sub_channel" + + /** Root、DPM 与 APK 安装均在后台串行执行,避免阻塞 Flutter 主线程和 Android UI。 */ + private val privilegedExecutor = Executors.newSingleThreadExecutor() + + /** 注册项目原生插件和运行时通道;耗时特权命令始终转交后台执行器。 */ override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) // 人脸识别插件 val flutterFaceDiscernPlugin = FlutterFaceDiscernPlugin() flutterEngine.plugins.add(flutterFaceDiscernPlugin) + flutterEngine.plugins.add(FlutterFaceSubScreenPlugin()) + + // 副屏是独立 Flutter Engine,需要独立的人脸通道和 TextureRegistry; + // 两个插件实例最终共享同一个进程级 Camera/虹软 Session。 + FlutterSubscreenPlugin.registerThirdPlugins( + arrayListOf(FlutterFaceSubScreenPlugin()), + flutterEngine.plugins + ) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, runtimeChannel).setMethodCallHandler { call, result -> + when (call.method) { + "getRuntimeEnvironment" -> { + val displayManager = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + val packageInfo = packageManager.getPackageInfo(packageName, 0) + @Suppress("DEPRECATION") + val apkVersionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + packageInfo.longVersionCode + } else { + packageInfo.versionCode.toLong() + } + result.success( + mapOf( + "isEmulator" to isEmulator(), + "displayCount" to displayManager.displays.size, + // 系统和安装包版本来自当前进程实际运行环境,不能使用后台目标版本代替。 + "androidVersion" to Build.VERSION.RELEASE, + "androidSdkInt" to Build.VERSION.SDK_INT, + "apkVersionName" to (packageInfo.versionName ?: ""), + "apkVersionCode" to apkVersionCode + ) + ) + } + "getNetworkSnapshot" -> result.success(getNetworkSnapshot()) + "getPerformanceSnapshot" -> result.success(getPerformanceSnapshot()) + "vibrate" -> { + val duration = call.argument("durationMs")?.toLong() ?: 60L + result.success(vibrateForBlockedDialog(duration.coerceIn(20L, 200L))) + } + "isSubScreenShowing" -> result.success( + FlutterSubScreenProvider.instance.presentation?.isShowing == true + ) + "sendViceMessage" -> { + val method = call.argument("method") + val params = call.argument>("params") ?: emptyMap() + if (method.isNullOrBlank()) { + result.error("INVALID_ARGUMENT", "缺少副屏消息方法", null) + } else { + val subEngine = FlutterSubScreenProvider.instance.flutterEngine + if (subEngine == null) { + result.success(false) + } else { + try { + // 只做单向通知,不注册回调等待副屏;副屏异常不能占住主屏硬件链路。 + MethodChannel(subEngine.dartExecutor.binaryMessenger, subScreenChannel) + .invokeMethod(method, params) + result.success(true) + } catch (error: Exception) { + result.error("VICE_MESSAGE_FAILED", "副屏消息转发失败", error.message) + } + } + } + } + "getKioskStatus" -> result.success(KioskModeManager.status(this)) + "checkRootAccess" -> runPrivileged(result) { + RootCommandRunner.checkRoot() + } + "enableKioskWithRoot" -> { + privilegedExecutor.execute { + val provision = try { + KioskModeManager.provisionDeviceOwnerWithRoot(applicationContext) + } catch (error: Exception) { + mapOf( + "success" to false, + "code" to "DPM_FAILED", + "message" to (error.message ?: "Device Owner 配置失败") + ) + } + runOnUiThread { + if (provision["success"] == true) { + KioskModeManager.setAutoEnterEnabled(this, true) + } + result.success(provision + ("status" to KioskModeManager.status(this))) + } + } + } + "setKioskAutoEnterEnabled" -> { + val enabled = call.argument("enabled") + if (enabled == null) { + result.error("INVALID_ARGUMENT", "缺少 Kiosk 自动进入配置", null) + } else { + result.success(KioskModeManager.setAutoEnterEnabled(this, enabled)) + } + } + "exitKioskToHome" -> result.success(KioskModeManager.exitToHome(this)) + "validateApk" -> { + val path = call.argument("path") + val expectedVersionCode = call.argument("expectedVersionCode")?.toLong() + if (path.isNullOrBlank() || expectedVersionCode == null) { + result.error("INVALID_ARGUMENT", "缺少 APK 路径或目标版本码", null) + } else { + runPrivileged(result) { + ApkUpdateManager.validate(applicationContext, path, expectedVersionCode) + } + } + } + "installApkWithRoot" -> { + val path = call.argument("path") + val expectedVersionCode = call.argument("expectedVersionCode")?.toLong() + if (path.isNullOrBlank() || expectedVersionCode == null) { + result.error("INVALID_ARGUMENT", "缺少 APK 路径或目标版本码", null) + } else { + runPrivileged(result) { + ApkUpdateManager.install(applicationContext, path, expectedVersionCode) + } + } + } + "consumePendingUpdate" -> runPrivileged(result) { + ApkUpdateManager.consumePending(applicationContext) + } + else -> result.notImplemented() + } + } GeneratedPluginRegistrant.registerWith(flutterEngine) -// -// -// FlutterSubscreenPlugin.registerThirdPlugins( -// arrayListOf( -// -// ), -// this.flutterEngine!!.plugins -// ) } -} \ No newline at end of file + /** Activity 回到可交互状态时按原生持久化配置恢复 Kiosk。 */ + override fun onPostResume() { + super.onPostResume() + KioskModeManager.enterIfAvailable(this) + } + + /** 销毁主 Activity 时同步释放副屏 Engine 和特权命令线程,避免持有失效资源。 */ + override fun onDestroy() { + val subScreenProvider = FlutterSubScreenProvider.instance + try { + // Android 12 以下副屏使用 Overlay Presentation,任务被划掉时必须在 + // Activity 销毁前同步移除窗口并销毁独立 Engine,不能只依赖 Dart 生命周期回调。 + try { + subScreenProvider.closeSubDisplay() + } finally { + try { + subScreenProvider.flutterEngine?.destroy() + } finally { + subScreenProvider.onDispose() + } + } + } finally { + privilegedExecutor.shutdownNow() + super.onDestroy() + } + } + + /** 系统报告低内存时释放当前人脸识别会话的高占用资源。 */ + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + if (level >= TRIM_MEMORY_RUNNING_LOW) { + // 低内存时优先释放 Camera/EGL/会话级引擎,跨会话 FaceServer 特征库仍由进程保留。 + FaceRecognitionSessionManager.getInstance(applicationContext).releaseForLowMemory() + } + } + + /** 窗口重新获得焦点时恢复 Kiosk 系统栏隐藏策略。 */ + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) KioskModeManager.reapplySystemBars(this) + } + + @Suppress("DEPRECATION") + /** Kiosk 运行时拦截系统返回键,普通模式继续使用 FlutterActivity 默认行为。 */ + override fun onBackPressed() { + // 当前 FlutterActivity 基线没有 OnBackPressedDispatcher,Kiosk 运行时直接拦截系统返回。 + if (KioskModeManager.isKioskActive(this)) return + super.onBackPressed() + } + + /** + * 使用 Android Build 特征识别常见虚拟机,避免在虚拟设备上初始化串口、人脸和副屏硬件。 + */ + private fun isEmulator(): Boolean { + return Build.FINGERPRINT.startsWith("generic") || + Build.FINGERPRINT.lowercase().contains("emulator") || + Build.MODEL.contains("google_sdk") || + Build.MODEL.lowercase().contains("emulator") || + Build.MODEL.contains("Android SDK built for x86") || + Build.MANUFACTURER.lowercase().contains("genymotion") || + Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic") || + Build.PRODUCT == "google_sdk" || + Build.PRODUCT.lowercase().contains("sdk_gphone") || + Build.PRODUCT.lowercase().contains("vbox86p") || + Build.HARDWARE.lowercase().contains("goldfish") || + Build.HARDWARE.lowercase().contains("ranchu") + } + + /** + * 返回当前物理网络类型和累计流量。Flutter 侧只计算相邻快照差值,避免测速请求 + * 额外占用收银终端带宽,也不依赖外部测速服务是否可用。 + */ + private fun getNetworkSnapshot(): Map { + val connectivity = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val capabilities = connectivity.getNetworkCapabilities(connectivity.activeNetwork) + val type = when { + capabilities == null -> "offline" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> mobileNetworkType() + else -> "network" + } + return mapOf( + "type" to type, + "connected" to (capabilities != null), + "rxBytes" to TrafficStats.getTotalRxBytes().coerceAtLeast(0L), + "txBytes" to TrafficStats.getTotalTxBytes().coerceAtLeast(0L) + ) + } + + /** + * 返回轻量的进程与设备资源快照。CPU 使用率由 Flutter 比较两次累计进程时间计算, + * 原生层不创建额外线程或定时器,避免运行状态展示反过来增加终端负载。 + */ + private fun getPerformanceSnapshot(): Map { + val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val memoryInfo = ActivityManager.MemoryInfo() + activityManager.getMemoryInfo(memoryInfo) + val storage = StatFs(filesDir.absolutePath) + return mapOf( + "systemUptimeSeconds" to SystemClock.elapsedRealtime() / 1_000L, + "appUptimeSeconds" to (SystemClock.elapsedRealtime() - processStartedAtMillis).coerceAtLeast(0L) / 1_000L, + "processCpuTimeMs" to Process.getElapsedCpuTime(), + "logicalProcessorCount" to Runtime.getRuntime().availableProcessors().coerceAtLeast(1), + "memoryTotalBytes" to memoryInfo.totalMem, + "memoryAvailableBytes" to memoryInfo.availMem, + "appMemoryBytes" to Debug.getPss().toLong() * 1024L, + "storageTotalBytes" to storage.totalBytes, + "storageAvailableBytes" to storage.availableBytes + ) + } + + /** + * 为禁止关闭的业务弹框提供短震动提示;设备不具备震动器时返回 false 供 Flutter 回退。 + */ + @Suppress("DEPRECATION") + private fun vibrateForBlockedDialog(durationMillis: Long): Boolean { + val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator ?: return false + if (!vibrator.hasVibrator()) return false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + vibrator.vibrate(VibrationEffect.createOneShot(durationMillis, VibrationEffect.DEFAULT_AMPLITUDE)) + } else { + vibrator.vibrate(durationMillis) + } + return true + } + + @Suppress("DEPRECATION") + /** 将蜂窝网络制式归一为心跳接口使用的 2g/3g/4g/5g/mobile 编码。 */ + private fun mobileNetworkType(): String { + return try { + val telephony = getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager + when (telephony.dataNetworkType) { + TelephonyManager.NETWORK_TYPE_NR -> "5g" + TelephonyManager.NETWORK_TYPE_LTE -> "4g" + TelephonyManager.NETWORK_TYPE_UMTS, + TelephonyManager.NETWORK_TYPE_HSDPA, + TelephonyManager.NETWORK_TYPE_HSUPA, + TelephonyManager.NETWORK_TYPE_HSPA, + TelephonyManager.NETWORK_TYPE_HSPAP -> "3g" + TelephonyManager.NETWORK_TYPE_GPRS, + TelephonyManager.NETWORK_TYPE_EDGE, + TelephonyManager.NETWORK_TYPE_CDMA, + TelephonyManager.NETWORK_TYPE_1xRTT -> "2g" + else -> "mobile" + } + } catch (_: SecurityException) { + "mobile" + } + } + + /** + * 在专用后台线程执行高延迟原生动作,并始终切回主线程完成 MethodChannel 回调。 + */ + private fun runPrivileged(result: MethodChannel.Result, action: () -> Any?) { + privilegedExecutor.execute { + try { + val payload = action() + runOnUiThread { result.success(payload) } + } catch (error: Exception) { + runOnUiThread { + result.error("PRIVILEGED_OPERATION_FAILED", error.message, null) + } + } + } + } + +} diff --git a/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/RootCommandRunner.kt b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/RootCommandRunner.kt new file mode 100644 index 0000000..9fbf30e --- /dev/null +++ b/android/app/src/main/kotlin/com/example/pj_supermarket_cloud/RootCommandRunner.kt @@ -0,0 +1,191 @@ +package com.example.pj_supermarket_cloud + +import android.os.SystemClock +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * 统一执行需要 Root 的短生命周期 shell 命令。 + * + * 所有调用方必须显式给出超时;标准错误并入标准输出并限制返回长度,避免子进程管道阻塞 + * 或把不可控的系统输出长期保留在 Flutter 层。 + */ +object RootCommandRunner { + /** Root 检查固定超时,超过 5 秒按不可用处理。 */ + private const val ROOT_CHECK_TIMEOUT_MILLIS = 5_000L + + /** 返回给上层的最大诊断字符数。 */ + private const val MAX_OUTPUT_LENGTH = 2_000 + + /** 并发读取子进程输出,防止输出缓冲区写满后命令无法退出。 */ + private val outputExecutor = Executors.newCachedThreadPool() + + /** + * 当前进程已经确认可用的 su 参数格式;首次调用时为空,由只读 Root 检查或语法回退确定。 + */ + @Volatile + private var preferredInvocationMode: SuInvocationMode? = null + + /** + * Android 设备现场存在两类常见 su 参数协议,枚举值只描述进程参数格式,不改变业务命令。 + */ + private enum class SuInvocationMode { + /** Magisk、KernelSU 和 SuperSU 常见的 `su -c ` 格式。 */ + OPTION_C, + + /** AOSP userdebug 与部分厂商固件要求显式 UID 的 `su 0 sh -c ` 格式。 */ + UID_ZERO_SHELL + } + + /** + * 一次 Root 命令执行结果。 + * + * @property exitCode 进程退出码;超时或无法启动时为空 + * @property output 合并并截断后的标准输出与错误输出 + * @property timedOut 是否因超过调用方限制被终止 + */ + data class CommandResult( + val exitCode: Int?, + val output: String, + val timedOut: Boolean + ) + + /** + * 使用设备支持的 su 参数格式执行命令并轮询退出状态,兼容项目最低 Android 版本。 + * + * 部分厂商 su 会把 `-c` 当成 UID 并返回 `invalid uid/gid '-c'`。仅识别到这个明确的 + * 参数语法错误时才回退到 `su 0 sh -c`,因此不会把可能已经执行过的安装或 DPM 命令 + * 重复执行;两次尝试共享调用方给出的总超时。 + * + * @param command 传给 Root shell 的完整命令 + * @param timeoutMillis 两种参数格式合计允许的最大执行时间,单位毫秒 + * @return 包含退出码、诊断输出与超时状态的结果 + */ + fun execute(command: String, timeoutMillis: Long): CommandResult { + val normalizedTimeout = timeoutMillis.coerceAtLeast(1L) + val preferred = preferredInvocationMode + if (preferred != null) { + return executeWithMode(command, normalizedTimeout, preferred) + } + + val startedAt = SystemClock.elapsedRealtime() + val optionResult = executeWithMode(command, normalizedTimeout, SuInvocationMode.OPTION_C) + if (!isOptionCSyntaxRejected(optionResult)) { + if (!optionResult.timedOut && optionResult.exitCode != null) { + preferredInvocationMode = SuInvocationMode.OPTION_C + } + return optionResult + } + + val elapsed = SystemClock.elapsedRealtime() - startedAt + val remaining = (normalizedTimeout - elapsed).coerceAtLeast(1L) + val uidResult = executeWithMode(command, remaining, SuInvocationMode.UID_ZERO_SHELL) + if (!uidResult.timedOut && uidResult.exitCode != null) { + preferredInvocationMode = SuInvocationMode.UID_ZERO_SHELL + } + return uidResult + } + + /** + * 按指定 su 参数协议启动一次子进程;本方法不进行协议回退,避免特权命令重复执行。 + * + * @param command 传给 Root shell 的完整命令 + * @param timeoutMillis 本次进程允许执行的最长时间,单位毫秒 + * @param mode 本次使用的 su 参数协议 + * @return 单次进程的退出码、诊断输出和超时状态 + */ + private fun executeWithMode( + command: String, + timeoutMillis: Long, + mode: SuInvocationMode + ): CommandResult { + var process: Process? = null + return try { + val processCommand = when (mode) { + SuInvocationMode.OPTION_C -> listOf("su", "-c", command) + SuInvocationMode.UID_ZERO_SHELL -> listOf("su", "0", "sh", "-c", command) + } + val runningProcess = ProcessBuilder(processCommand) + .redirectErrorStream(true) + .start() + process = runningProcess + val outputFuture = outputExecutor.submit { + runningProcess.inputStream.bufferedReader().use { it.readText() } + } + val deadline = SystemClock.elapsedRealtime() + timeoutMillis + var exitCode: Int? = null + while (SystemClock.elapsedRealtime() <= deadline) { + exitCode = try { + runningProcess.exitValue() + } catch (_: IllegalThreadStateException) { + null + } + if (exitCode != null) break + Thread.sleep(50) + } + val timedOut = exitCode == null + if (timedOut) runningProcess.destroy() + val output = try { + outputFuture.get(500, TimeUnit.MILLISECONDS) + } catch (_: Exception) { + "" + } + CommandResult(exitCode, sanitize(output), timedOut) + } catch (error: Exception) { + process?.destroy() + CommandResult(null, sanitize(error.message ?: "无法执行 su 命令"), false) + } + } + + /** + * 判断标准 `su -c` 是否被设备误当成 UID/GID 或未知选项。 + * + * 只有命中明确参数错误才允许换协议重试;授权拒绝、命令失败和超时都原样返回,避免 + * DPM 或 APK 安装等具有副作用的命令被执行两次。 + */ + private fun isOptionCSyntaxRejected(result: CommandResult): Boolean { + if (result.timedOut || result.exitCode == null || result.exitCode == 0) return false + val output = result.output.lowercase() + val mentionsOptionC = "'-c'" in output || "\"-c\"" in output || " -c" in output + return mentionsOptionC && ( + "invalid uid/gid" in output || + "invalid uid" in output || + "unknown option" in output || + "unrecognized option" in output || + "illegal option" in output + ) + } + + /** + * 真实执行 Root shell 的 `id` 检查;不能只根据 su 文件是否存在进行判断。 + */ + fun checkRoot(): Map { + val command = execute("id", ROOT_CHECK_TIMEOUT_MILLIS) + val available = command.exitCode == 0 && Regex("(^|\\s)uid=0([\\s(]|$)") + .containsMatchIn(command.output) + val message = when { + command.timedOut -> "Root 授权超时(5秒),请检查 su 授权策略" + available -> "已通过 su 执行 id 确认 uid=0" + command.exitCode == null -> command.output.ifBlank { "未找到 su 或 Root 命令无法启动" } + command.output.isBlank() -> "su 返回退出码 ${command.exitCode},未确认 uid=0" + else -> command.output + } + return mapOf( + "available" to available, + "exitCode" to (command.exitCode ?: -1), + "timedOut" to command.timedOut, + "message" to message + ) + } + + /** + * 使用单引号安全包裹 shell 路径,避免空格或特殊字符改变安装命令含义。 + */ + fun quoteShell(value: String): String = "'${value.replace("'", "'\\''")}'" + + /** 将多行命令输出压缩为界面可读摘要并限制长度。 */ + private fun sanitize(output: String): String { + val compact = output.trim().replace(Regex("\\s+"), " ") + return if (compact.length <= MAX_OUTPUT_LENGTH) compact else compact.take(MAX_OUTPUT_LENGTH) + "..." + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..58a1cbe --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + 收银终端设备管理 + 用于启用开机自启和专用设备锁定模式 + diff --git a/android/app/src/main/res/xml/kiosk_device_admin.xml b/android/app/src/main/res/xml/kiosk_device_admin.xml new file mode 100644 index 0000000..8b4c99b --- /dev/null +++ b/android/app/src/main/res/xml/kiosk_device_admin.xml @@ -0,0 +1,4 @@ + + + + diff --git a/lib/app.dart b/lib/app.dart index 82d2d79..cb675fc 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -29,6 +29,7 @@ class _MainAppState extends State with WidgetsBindingObserver { @override void didChangeAppLifecycleState(AppLifecycleState state) async { + SyncIdleTool.instance.updateLifecycle(state); if (state == AppLifecycleState.paused || state == AppLifecycleState.detached) { try { @@ -81,9 +82,12 @@ class _MainAppState extends State with WidgetsBindingObserver { GlobalRouteObserver(), ], builder: EasyLoading.init( - builder: (_, childSecond) => FTheme( - data: themeData, - child: childSecond!, + builder: (_, childSecond) => Listener( + onPointerDown: (_) => SyncIdleTool.instance.recordUserActivity(), + child: FTheme( + data: themeData, + child: childSecond!, + ), ), ), ); diff --git a/lib/common/api/app_api.dart b/lib/common/api/app_api.dart index b7f6791..66bd2b4 100644 --- a/lib/common/api/app_api.dart +++ b/lib/common/api/app_api.dart @@ -1,13 +1,24 @@ +import 'package:dio/dio.dart' as dio; + import 'package:pj_supermarket_cloud/common/index.dart'; +/// 终端 App 配置与版本接口入口,统一复用 HttpManage 的鉴权、租户和错误处理。 abstract class AppApi { - /// 获取所有版本列表 + /// + /// 分页获取匹配 App 编码和平台的已发布版本。 + /// + /// [pa] 包含 appUniqueCode、platform、current 和 size;[silent] 为 true 时后台任务 + /// 只记录异常,不弹业务 Toast。 static Future openCardList( - dynamic pa, - ) async { + dynamic pa, { + bool silent = false, + }) async { final res = await HttpManage().dio.get( "/integrated/app-version/ad/page", queryParameters: pa, + options: dio.Options( + extra: {if (silent) HttpManage.silentRequestKey: true}, + ), ); if (res.data == null) return null; return ServiceAppVersionPage.fromJson(res.data); diff --git a/lib/common/api/hard_api.dart b/lib/common/api/hard_api.dart index 276a7cf..e1b1257 100644 --- a/lib/common/api/hard_api.dart +++ b/lib/common/api/hard_api.dart @@ -1,6 +1,88 @@ +import 'dart:io'; + +import 'package:dio/dio.dart' as dio; + import 'package:pj_supermarket_cloud/common/index.dart'; abstract class HardApi { + /// 按终端订单号或服务端订单 ID 对账识别证据状态;后台任务失败不弹业务 Toast。 + static Future resolveOrderEvidence({ + required String hardId, + String? androidOrderCode, + String? orderId, + }) async { + final res = await HttpManage().dio.get( + '/hard/hard-order-evidence/ad/resolve', + queryParameters: { + if (androidOrderCode != null) 'androidOrderCode': androidOrderCode, + if (orderId != null) 'orderId': orderId, + }, + options: dio.Options( + headers: {'DEVICE-ID': hardId}, + extra: {HttpManage.silentRequestKey: true}, + ), + ); + if (res.data == null) return null; + return ServiceOrderEvidenceResolve.fromJson(res.data); + } + + /// 提交识别元数据、触发参考图复制或报告本地图片永久缺失。 + static Future submitOrderEvidence({ + required String hardId, + required OrderEvidenceRequest request, + }) async { + final res = await HttpManage().dio.post( + '/hard/hard-order-evidence/ad/submit', + data: request.toJson(), + options: dio.Options( + headers: {'DEVICE-ID': hardId}, + extra: {HttpManage.silentRequestKey: true}, + ), + ); + if (res.data == null) return null; + return ServiceOrderEvidenceUploadResult.fromJson(res.data); + } + + /// 使用统一 Dio 鉴权和租户头上传现场图片,multipart 文件名不包含终端完整路径。 + static Future uploadOrderEvidence({ + required String hardId, + required OrderEvidenceRequest request, + required String filePath, + }) async { + final file = File(filePath); + final fileName = file.uri.pathSegments.last; + final form = dio.FormData.fromMap({ + ...request.toJson(), + 'file': await dio.MultipartFile.fromFile(filePath, filename: fileName), + }); + final res = await HttpManage().dio.post( + '/hard/hard-order-evidence/ad/upload', + data: form, + options: dio.Options( + headers: {'DEVICE-ID': hardId}, + contentType: 'multipart/form-data', + extra: {HttpManage.silentRequestKey: true}, + ), + ); + if (res.data == null) return null; + return ServiceOrderEvidenceUploadResult.fromJson(res.data); + } + + /// 静默上报食堂、超市 Android 终端最新运行状态;后台失败不得弹出业务 Toast。 + static Future terminalHeartbeat({ + required String hardId, + required Map data, + }) async { + await HttpManage().dio.post( + '/hard/hard-api/ad/terminal-heartbeat', + data: data, + options: dio.Options( + headers: {'DEVICE-ID': hardId}, + extra: {HttpManage.silentRequestKey: true}, + ), + ); + } + /// 获取硬件配置信息 static Future bind(dynamic pa) async { final res = await HttpManage().dio.post( @@ -45,11 +127,92 @@ abstract class HardApi { return ServiceUser.fromJson(res.data); } + /// 仅按当前终端和识别身份查询本人预约,调用方不得补充商户或其他用户参数。 + static Future queryReservationMenu( + ReservationMenuQueryRequest request, + ) async { + final res = await HttpManage().dio.post( + "/hard/hard-api/ad/reservation-menu/query", + data: request.toJson(), + ); + if (res.data == null) return null; + return ServiceReservationMenuQuery.fromJson(res.data); + } + + /// 只按查询结果中的订单 ID 核销一笔预约订单。 + static Future cancelReservationOrder({ + required int orderId, + }) async { + final setting = await AppSettingTool.getSysSetting(); + final res = await HttpManage().dio.post( + '/hard/hard-api/ad/reservation-menu/cancel', + data: {'orderId': orderId}, + options: dio.Options( + headers: { + if (setting.serviceId != null) 'DEVICE-ID': setting.serviceId, + }, + ), + ); + return res.data == true; + } + + /// 一次提交多笔同一用户预约订单,服务端在同一事务内核销并合并生成一张兑换券。 + static Future cancelReservationOrders({ + required List orderIds, + }) async { + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) { + throw StateError('当前业务终端尚未绑定'); + } + final res = await HttpManage().dio.post( + '/hard/hard-api/ad/reservation-menu/cancel-batch', + data: {'orderIds': orderIds}, + options: dio.Options(headers: {'DEVICE-ID': hardId}), + ); + return res.data == true; + } + + /// 分页查询当前设备的预约核销记录,设备范围由服务端再次校验。 + static Future verificationRecordPage({ + required int current, + required int size, + required VerificationRecordQueryRequest request, + }) async { + final res = await HttpManage().dio.post( + '/hard/hard-api/ad/verification-record/page', + queryParameters: {'current': current, 'size': size}, + data: request.toJson(), + options: dio.Options(headers: {'DEVICE-ID': request.hardId}), + ); + if (res.data == null) return null; + return ServiceVerificationRecordPage.fromJson(res.data); + } + + /// 分页查询当前设备的交易记录;scope=MERCHANT 时按终端所属商户查询全部设备。 + static Future terminalOrderRecordPage({ + required int current, + required int size, + required TerminalOrderRecordQueryRequest request, + }) async { + final res = await HttpManage().dio.post( + '/hard/hard-api/ad/order-record/page', + queryParameters: {'current': current, 'size': size}, + data: request.toJson(), + options: dio.Options(headers: {'DEVICE-ID': '${request.hardId}'}), + ); + if (res.data == null) return null; + return ServiceTerminalOrderRecordPage.fromJson(res.data); + } + /// 创建硬件订单 static Future createHardwareOrder(dynamic formData) async { final res = await HttpManage().dio.post( "/hard/hard-api/ad/create-order", data: formData, + options: dio.Options( + extra: {HttpManage.rejectBusinessErrorKey: true}, + ), ); if (res.data == null) return null; return res.data; @@ -61,11 +224,45 @@ abstract class HardApi { final res = await HttpManage().dio.post( "/hard/hard-api/ad/create-hard-qr-code-order", data: formData, + options: dio.Options( + extra: {HttpManage.rejectBusinessErrorKey: true}, + ), ); if (res.data == null) return null; return ServiceHardCreateHardQrCodeScanOrder.fromJson(res.data); } + /// 创建与 Hardware 业务订单绑定的支付宝、微信聚合订单码。 + static Future createAggregateOrderCodePay( + dynamic formData) async { + final res = await HttpManage().dio.post( + "/hard/hard-api/ad/create-aggregate-order-code-pay", + data: formData, + options: dio.Options( + extra: {HttpManage.rejectBusinessErrorKey: true}, + ), + ); + if (res.data == null) return null; + return ServiceHardAggregateOrderCodePay.fromJson(res.data); + } + + /// 查询聚合支付状态;成功响应表示 Hardware 业务订单已同步完成结算。 + static Future + getAggregateOrderCodePayStatus({ + required String orderId, + required String aggregateNo, + }) async { + final res = await HttpManage().dio.get( + "/hard/hard-api/ad/aggregate-order-code-pay/status", + queryParameters: { + "orderId": orderId, + "aggregateNo": aggregateNo, + }, + ); + if (res.data == null) return null; + return ServiceHardAggregateOrderCodePayStatus.fromJson(res.data); + } + /// 根据订单id,获取订单详情 static Future getOrder(dynamic formData) async { final res = await HttpManage().dio.get( @@ -120,6 +317,69 @@ abstract class HardApi { return ServiceGenerateInvoice.fromJson(res.data); } + /// 业务终端按 DEVICE-ID 创建打印任务,目标打印机只由服务端绑定关系决定。 + static Future createPrinterTask({ + required String orderId, + String printType = '1', + }) async { + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) throw StateError('当前业务终端尚未绑定'); + final res = await HttpManage().dio.post( + '/hard/hard-api/ad/printer/task', + data: { + 'orderId': int.parse(orderId), + 'printType': printType, + }, + options: dio.Options(headers: {'DEVICE-ID': hardId}), + ); + return res.data != null; + } + + /// 在线查询当前业务终端为指定订单生成的打印凭证,不读取终端本地打印记录。 + static Future> getPrinterRecords({ + required String orderId, + }) async { + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) throw StateError('当前业务终端尚未绑定'); + final res = await HttpManage().dio.get( + '/hard/hard-api/ad/printer/records', + queryParameters: {'orderId': int.parse(orderId)}, + options: dio.Options(headers: {'DEVICE-ID': hardId}), + ); + if (res.data == null) return const []; + if (res.data is! List) throw const FormatException('打印凭证记录格式错误'); + return (res.data as List) + .map((item) => ServicePrintRecord.fromJson( + Map.from(item as Map), + )) + .toList(growable: false); + } + + /// 提交当前业务终端已打印兑换券的人工重打原因并取得新任务。 + static Future reprintVoucher({ + required String historyId, + required String reason, + }) async { + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) throw StateError('当前业务终端尚未绑定'); + final res = await HttpManage().dio.post( + '/hard/hard-api/ad/printer/reprint', + data: { + 'historyId': int.parse(historyId), + 'reprintReason': reason, + }, + options: dio.Options(headers: {'DEVICE-ID': hardId}), + ); + if (res.data == null) return null; + if (res.data is! Map) throw const FormatException('兑换券重打任务格式错误'); + return ServicePrintRecord.fromJson( + Map.from(res.data as Map), + ); + } + /// 打印成功 static Future printInvoice(dynamic formData) async { final res = await HttpManage().dio.get( @@ -130,6 +390,85 @@ abstract class HardApi { return true; } + /// 读取当前食堂或超市终端绑定的逻辑打印机及 USB/ESC-POS 参数。 + static Future?> getPrinterBinding() async { + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) throw StateError('当前业务终端尚未绑定设备 ID'); + final res = await HttpManage().dio.get( + '/hard/hard-api/ad/printer/bootstrap', + options: dio.Options(headers: {'DEVICE-ID': hardId}), + ); + if (res.data == null) return null; + if (res.data is! Map) throw const FormatException('打印机绑定配置格式错误'); + return Map.from(res.data as Map); + } + + /// 由当前业务终端把实际枚举到的 USB 打印机型号和 VID/PID 上报到绑定的 EY_PRINT 配置。 + static Future> reportPrinterInfo({ + required String hardId, + required PrinterInfoReportRequest request, + }) async { + final res = await HttpManage().dio.post( + '/hard/hard-api/ad/printer/info', + data: request.toJson(), + options: dio.Options(headers: {'DEVICE-ID': hardId}), + ); + if (res.data is! Map) throw const FormatException('打印机信息上报响应格式错误'); + return Map.from(res.data as Map); + } + + /// 拉取当前业务终端所绑定打印机的未完成任务,补偿 MQTT 离线窗口。 + static Future> getPrinterTasks() async { + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) return const []; + final res = await HttpManage().dio.get( + '/hard/hard-api/ad/printer/tasks', + options: dio.Options( + headers: {'DEVICE-ID': hardId}, + extra: {HttpManage.silentRequestKey: true}, + ), + ); + if (res.data is! List) return const []; + return (res.data as List).whereType().map((item) { + final json = Map.from(item); + json['historyId'] ??= json['id']; + return MqttInvoiceMsg.fromJson(json); + }).toList(growable: false); + } + + /// 由当前业务终端上报完整 USB 写入结果;请求失败由本地队列保留并在网络恢复后补报。 + static Future reportPrinterResult({ + required String taskId, + required String orderId, + required int retryVersion, + required bool success, + required int attemptCount, + String? errorMessage, + }) async { + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) throw StateError('业务终端设备 ID 缺失'); + final res = await HttpManage().dio.post( + '/hard/hard-api/ad/printer/result', + data: { + 'taskId': int.parse(taskId), + 'orderId': int.parse(orderId), + 'retryVersion': retryVersion, + 'success': success, + 'attemptCount': attemptCount, + if (errorMessage != null && errorMessage.isNotEmpty) + 'errorMessage': errorMessage, + }, + options: dio.Options( + headers: {'DEVICE-ID': hardId}, + extra: {HttpManage.silentRequestKey: true}, + ), + ); + return res.data == true; + } + /// 离线同步 static Future?> syncOfflineOrder(dynamic formData) async { final res = await HttpManage().dio.post( diff --git a/lib/common/api/user_api.dart b/lib/common/api/user_api.dart index c0687a4..12504f1 100644 --- a/lib/common/api/user_api.dart +++ b/lib/common/api/user_api.dart @@ -75,6 +75,104 @@ abstract class UserApi { .toList(); } + /// 按固定主键上界游标读取本部门及子部门的 V3 人脸权威快照。 + static Future getFaceSyncSnapshot( + Map requestBody, + ) async { + final res = await HttpManage().dio.post( + "/admin/user-face/ad/v3/sync-snapshot", + data: requestBody, + ); + if (res.data is! Map) { + throw const FormatException('服务端未返回有效的人脸快照'); + } + return ServiceFaceSyncSnapshot.fromJson( + Map.from(res.data as Map), + ); + } + + /// 由当前设备请求删除服务端部门范围内的人脸,单批最多 500 条。 + /// + /// 服务端会以 DEVICE-ID 解析真实绑定部门;业务错误必须抛给调用方,确保终端 + /// 在未得到 deleted 或 alreadyAbsent 确认前不清理本地数据。 + static Future deleteTerminalFaces( + List sampleIds, + ) async { + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null) throw StateError('终端尚未绑定设备'); + final res = await HttpManage().dio.post( + '/hard/hard-api/ad/face/delete', + data: {'sampleIds': sampleIds}, + options: dio.Options( + headers: {'DEVICE-ID': hardId}, + extra: {HttpManage.rejectBusinessErrorKey: true}, + ), + ); + if (res.data is! Map) { + throw const FormatException('服务端未返回有效的人脸删除结果'); + } + return ServiceTerminalFaceDeleteResult.fromJson( + Map.from(res.data as Map), + ); + } + + /// 由当前设备批量上报在库人脸静态图片质量,单批最多 500 条。 + /// + /// 服务端通过 DEVICE-ID 解析可信设备和部门;业务失败必须抛出,但调用方应将 + /// 质量上报与既有人脸同步、注册和识别结果隔离,网络失败不得回滚本地质量。 + static Future reportTerminalFaceQuality( + List items, + ) async { + if (items.isEmpty) throw ArgumentError.value(items, 'items', '质量结果不能为空'); + if (items.length > 500) { + throw ArgumentError.value(items.length, 'items', '单批最多上报500条质量结果'); + } + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null) throw StateError('终端尚未绑定设备'); + final res = await HttpManage().dio.post( + '/hard/hard-api/ad/face/quality/report', + data: { + 'items': items.map((item) => item.toJson()).toList(growable: false), + }, + options: dio.Options( + headers: {'DEVICE-ID': hardId}, + extra: {HttpManage.rejectBusinessErrorKey: true}, + ), + ); + if (res.data is! Map) { + throw const FormatException('服务端未返回有效的人脸质量上报结果'); + } + return ServiceFaceQualityReportResult.fromJson( + Map.from(res.data as Map), + ); + } + + /// 竞争当前商户部门的人脸质量上报租约。 + /// + /// Hardware 仍会在实际上报时二次验证租约;未取得租约不是业务失败,调用方应 + /// 直接跳过本轮质量批次,避免同一单位的多台终端重复发送相同在库结果。 + static Future + acquireTerminalFaceQualityLease() async { + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null) throw StateError('终端尚未绑定设备'); + final res = await HttpManage().dio.post( + '/hard/hard-api/ad/face/quality/lease', + options: dio.Options( + headers: {'DEVICE-ID': hardId}, + extra: {HttpManage.rejectBusinessErrorKey: true}, + ), + ); + if (res.data is! Map) { + throw const FormatException('服务端未返回有效的人脸质量租约'); + } + return ServiceFaceQualityLease.fromJson( + Map.from(res.data as Map), + ); + } + /// 获取租户列表 static Future?> getTenantList() async { final res = await HttpManage().dio.get("/admin/tenant/list"); diff --git a/lib/common/models/api/hard_api_models.dart b/lib/common/models/api/hard_api_models.dart index 52fe46d..a15d5e5 100644 --- a/lib/common/models/api/hard_api_models.dart +++ b/lib/common/models/api/hard_api_models.dart @@ -4,6 +4,340 @@ import 'package:pj_supermarket_cloud/common/index.dart'; part 'hard_api_models.g.dart'; +/// 终端提交订单识别证据元数据和现场图片时共用的强类型请求。 +@JsonSerializable(includeIfNull: false) +class OrderEvidenceRequest { + /// 创建一条与服务端证据位置协议一致的请求。 + const OrderEvidenceRequest({ + this.androidOrderCode, + this.orderId, + required this.evidenceType, + required this.evidenceRole, + required this.sequenceNo, + required this.userId, + this.faceSampleId, + this.referenceImageUrl, + this.referenceImageVersion, + this.similarity, + this.similarityThreshold, + required this.capturedAt, + this.imageHash, + this.imageSize, + this.imageWidth, + this.imageHeight, + this.status, + this.failureReason, + required this.appVersion, + }); + + /// 终端稳定订单号,用于服务端按设备对账现场创建的订单;预约核销证据允许为空。 + final String? androidOrderCode; + + /// 已由当前设备核销的服务端预约订单 ID;现场创建的订单证据允许为空。 + final String? orderId; + + /// 证据业务类型,使用 `OrderEvidenceType.code`。 + final String evidenceType; + + /// 图片角色,使用 `OrderEvidenceRole.code`。 + final String evidenceRole; + + /// 同一订单、类型和角色下从 0 开始的序号。 + final int sequenceNo; + + /// 识别用户主键,服务端会再次校验与订单用户一致。 + final String userId; + + /// 虹软样本编号;人脸证据必填,其他类型允许为空。 + final String? faceSampleId; + + /// 识别时记录的后台参考人脸地址,仅参考图角色提交。 + final String? referenceImageUrl; + + /// 识别时本地人脸快照对应的后台更新时间,用于识别相同地址上的内容替换。 + final DateTime? referenceImageVersion; + + /// 人脸比对相似度快照,范围 0 到 1。 + final double? similarity; + + /// 人脸比对成功阈值快照,范围 0 到 1。 + final double? similarityThreshold; + + /// 真正比对成功帧的终端采集时间。 + final DateTime capturedAt; + + /// 图片 SHA-256;现场图片必填,参考图内容未知时允许为空。 + final String? imageHash; + + /// 图片大小,单位字节。 + final int? imageSize; + + /// 图片宽度,单位像素。 + final int? imageWidth; + + /// 图片高度,单位像素。 + final int? imageHeight; + + /// 元数据提交目标状态,仅允许服务端协议的 PENDING 或 UNAVAILABLE。 + final String? status; + + /// 永久缺失的单行摘要,不得包含终端完整文件路径。 + final String? failureReason; + + /// 产生证据的终端应用版本快照。 + final String appVersion; + + /// 从 JSON 响应或测试夹具恢复请求对象。 + factory OrderEvidenceRequest.fromJson(Map json) => + _$OrderEvidenceRequestFromJson(json); + + /// 转换为统一 Dio 可提交的 JSON 或 multipart 字段 Map。 + Map toJson() => _$OrderEvidenceRequestToJson(this); +} + +/// 服务端单条订单识别证据状态,用于终端对账和幂等恢复。 +@JsonSerializable() +class ServiceOrderEvidenceImage { + /// 创建一条服务端证据状态快照。 + const ServiceOrderEvidenceImage({ + required this.id, + required this.evidenceType, + this.evidenceTypeName, + required this.evidenceRole, + this.evidenceRoleName, + required this.sequenceNo, + required this.status, + this.statusName, + this.similarity, + this.similarityThreshold, + this.hardId, + this.capturedAt, + this.imageHash, + this.imageUrl, + this.failureReason, + }); + + /// 服务端证据记录主键。 + @JsonKey(fromJson: JsonTool.anyToRequiredString) + final String id; + + /// 服务端稳定证据类型编码。 + final String evidenceType; + + /// 服务端按稳定编码转换的证据类型中文名称。 + final String? evidenceTypeName; + + /// 服务端稳定图片角色编码。 + final String evidenceRole; + + /// 服务端按稳定编码转换的图片角色中文名称。 + final String? evidenceRoleName; + + /// 同一角色下的图片序号。 + final int sequenceNo; + + /// 服务端证据状态,取 PENDING、AVAILABLE 或 UNAVAILABLE。 + final String status; + + /// 服务端按稳定编码转换的证据状态中文名称。 + final String? statusName; + + /// 人脸识别相似度快照,范围为 0 到 1,非人脸凭证允许为空。 + @JsonKey(fromJson: JsonTool.anyToNullableDouble) + final double? similarity; + + /// 人脸识别采用的阈值快照,范围为 0 到 1。 + @JsonKey(fromJson: JsonTool.anyToNullableDouble) + final double? similarityThreshold; + + /// 采集该凭证的服务端设备 ID。 + @JsonKey(fromJson: JsonTool.anyToString) + final String? hardId; + + /// 终端真正完成识别的本地采集时间。 + final DateTime? capturedAt; + + /// 服务端已登记的图片 SHA-256。 + final String? imageHash; + + /// PC 可访问的服务端证据图片地址。 + final String? imageUrl; + + /// 永久缺失或复制失败摘要。 + final String? failureReason; + + /// 从服务端 JSON 解析证据状态。 + factory ServiceOrderEvidenceImage.fromJson(Map json) => + _$ServiceOrderEvidenceImageFromJson(json); + + /// 转换为测试或诊断使用的 JSON。 + Map toJson() => _$ServiceOrderEvidenceImageToJson(this); +} + +/// 服务端打印任务的只读快照,用于核销完整详情展示和兑换券重打定位。 +@JsonSerializable() +class ServicePrintRecord { + /// 创建一条不写入终端本地数据库的服务端打印任务快照。 + const ServicePrintRecord({ + required this.id, + required this.orderId, + this.hardId, + this.hardName, + this.printerHardId, + this.printerHardName, + required this.printType, + required this.printNo, + required this.retryVersion, + required this.attemptCount, + required this.status, + this.failureStage, + this.lastError, + this.printedAt, + this.reprintReason, + this.pic, + this.createTime, + }); + + /// 服务端 hard_invoice 打印任务主键。 + @JsonKey(fromJson: JsonTool.anyToRequiredString) + final String id; + + /// 打印凭证所属的服务端订单主键。 + @JsonKey(fromJson: JsonTool.anyToRequiredString) + final String orderId; + + /// 产生该任务的食堂或超市业务终端 ID。 + @JsonKey(fromJson: JsonTool.anyToString) + final String? hardId; + + /// 产生该任务的食堂或超市业务终端名称。 + final String? hardName; + + /// 服务端绑定的逻辑打印机资产 ID。 + @JsonKey(fromJson: JsonTool.anyToString) + final String? printerHardId; + + /// 服务端绑定的逻辑打印机资产名称。 + final String? printerHardName; + + /// 凭证类型编码,1 为发票,2 为核销兑换券。 + @JsonKey(defaultValue: '1') + final String printType; + + /// 同一订单和凭证类型下从 1 开始的打印序号。 + @JsonKey(defaultValue: 1) + final int printNo; + + /// 失败任务的人工重试版本,用于终端过滤过期 MQTT 消息。 + @JsonKey(defaultValue: 0) + final int retryVersion; + + /// 终端已经执行的 USB 打印尝试次数。 + @JsonKey(defaultValue: 0) + final int attemptCount; + + /// 服务端打印任务状态编码,取 0 到 4。 + @JsonKey(defaultValue: '') + final String status; + + /// 最近一次失败阶段的稳定编码,成功和未处理任务允许为空。 + final String? failureStage; + + /// 最近一次可展示的失败摘要,不包含认证或支付凭证。 + final String? lastError; + + /// 终端确认完整写入 USB 打印机的服务端记录时间。 + final DateTime? printedAt; + + /// 人工重新打印时填写的原因,首次打印允许为空。 + final String? reprintReason; + + /// 后端生成的打印凭证图片地址,生成前或生成失败时允许为空。 + final String? pic; + + /// 服务端打印任务创建时间。 + final DateTime? createTime; + + /// 从终端专用在线接口解析打印任务。 + factory ServicePrintRecord.fromJson(Map json) => + _$ServicePrintRecordFromJson(json); + + /// 转换为测试和诊断使用的 JSON。 + Map toJson() => _$ServicePrintRecordToJson(this); +} + +/// 按终端订单号对账返回的服务端订单和全部证据位置。 +@JsonSerializable() +class ServiceOrderEvidenceResolve { + /// 创建一份服务端订单对账结果。 + const ServiceOrderEvidenceResolve({ + required this.orderId, + required this.orderStatus, + this.evidenceImages = const [], + }); + + /// 服务端订单主键。 + @JsonKey(fromJson: JsonTool.anyToRequiredString) + final String orderId; + + /// 服务端订单状态编码,只有已支付、已核销或退款订单允许继续补传。 + final String orderStatus; + + /// 服务端当前已登记的证据位置及状态。 + final List evidenceImages; + + /// 从统一响应中的 data 对象解析订单对账结果。 + factory ServiceOrderEvidenceResolve.fromJson(Map json) => + _$ServiceOrderEvidenceResolveFromJson(json); + + /// 转换为测试或诊断使用的 JSON。 + Map toJson() => _$ServiceOrderEvidenceResolveToJson(this); +} + +/// 服务端提交元数据或上传现场图片后的幂等结果。 +@JsonSerializable() +class ServiceOrderEvidenceUploadResult { + /// 创建一份服务端证据写入结果。 + const ServiceOrderEvidenceUploadResult({ + required this.evidenceId, + required this.orderId, + required this.status, + required this.uploaded, + required this.message, + this.imageUrl, + }); + + /// 服务端证据记录主键。 + @JsonKey(fromJson: JsonTool.anyToRequiredString) + final String evidenceId; + + /// 服务端订单主键。 + @JsonKey(fromJson: JsonTool.anyToRequiredString) + final String orderId; + + /// 服务端证据状态。 + final String status; + + /// 本次请求是否实际新写入文件;幂等重复成功时为 false。 + final bool uploaded; + + /// 面向任务日志的简短服务端处理结果。 + final String message; + + /// AVAILABLE 时的服务端证据图片地址。 + final String? imageUrl; + + /// 从统一响应中的 data 对象解析上传结果。 + factory ServiceOrderEvidenceUploadResult.fromJson( + Map json, + ) => + _$ServiceOrderEvidenceUploadResultFromJson(json); + + /// 转换为测试或诊断使用的 JSON。 + Map toJson() => + _$ServiceOrderEvidenceUploadResultToJson(this); +} + @JsonSerializable() class ServiceHardware extends ServiceBase { /// 编号 @@ -95,9 +429,11 @@ class ServiceHardware extends ServiceBase { @JsonSerializable() class ServiceCar { /// 编号 + @JsonKey(fromJson: JsonTool.anyToString) final String? id; /// 父购物车id + @JsonKey(fromJson: JsonTool.anyToString) final String? parentCarId; /// 商品名 @@ -107,13 +443,24 @@ class ServiceCar { final int? productPrice; /// 商品id + @JsonKey(fromJson: JsonTool.anyToString) final String? productId; /// 数量 final int? productCount; - /// 总价(分) - @JsonKey(fromJson: JsonTool.stringToInt, toJson: JsonTool.intToString) + /// 商品性质,1 为按件商品,2 为称重商品。 + final String? productNature; + + /// 称重商品重量,单位沿用服务端购物车快照。 + @JsonKey(fromJson: JsonTool.anyToNullableDouble) + final double? productWeight; + + /// 总价(分),兼容服务端 Long 返回数字或字符串。 + @JsonKey( + fromJson: JsonTool.anyToNullableInt, + toJson: JsonTool.intToString, + ) final int? totalPrice; /// 商品图片 @@ -126,6 +473,8 @@ class ServiceCar { this.productPrice, this.productId, this.productCount, + this.productNature, + this.productWeight, this.totalPrice, this.productPic, }); @@ -212,7 +561,15 @@ class ServiceQRCode { @JsonSerializable() class ServiceHardOrder { + /// 服务端订单主键。 + @JsonKey(fromJson: JsonTool.anyToString) + final String? id; + + /// 业务订单号。 + final String? orderNo; + /// 用户id + @JsonKey(fromJson: JsonTool.anyToString) final String? userId; /// 用户名 @@ -233,10 +590,43 @@ class ServiceHardOrder { /// 支付方式 final String? payType; - /// 支付方式 + /// 钱包id + @JsonKey(fromJson: JsonTool.anyToString) final String? walletId; + /// 用餐时段。 + final String? hourType; + + /// 预约用餐时段,旧预约订单可能只返回该字段。 + final String? apptHourType; + + /// 商品摘要。 + final String? productDesc; + + /// 用户所属部门。 + final String? userDeptName; + + /// 商品总价(分)。 + @JsonKey(fromJson: JsonTool.anyToNullableInt) + final int? totalPrice; + + /// 额外支付(分)。 + @JsonKey(fromJson: JsonTool.anyToNullableInt) + final int? extraPrice; + + /// 实际支付(分)。 + @JsonKey(fromJson: JsonTool.anyToNullableInt) + final int? payPrice; + + /// 终端订单号。 + final String? androidOrderCode; + + /// 下单时间。 + final DateTime? createTime; + ServiceHardOrder({ + this.id, + this.orderNo, this.userId, this.username, this.userMobile, @@ -245,6 +635,15 @@ class ServiceHardOrder { this.orderStatus, this.payType, this.walletId, + this.hourType, + this.apptHourType, + this.productDesc, + this.userDeptName, + this.totalPrice, + this.extraPrice, + this.payPrice, + this.androidOrderCode, + this.createTime, }); factory ServiceHardOrder.fromJson(Map json) => @@ -253,6 +652,185 @@ class ServiceHardOrder { Map toJson() => _$ServiceHardOrderToJson(this); } +/// 当前终端预约核销记录的服务端查询参数。 +@JsonSerializable(includeIfNull: false) +class VerificationRecordQueryRequest { + const VerificationRecordQueryRequest({ + required this.hardId, + required this.startDate, + required this.endDate, + this.verificationType, + this.scope, + }); + + final int hardId; + final String startDate; + final String endDate; + final String? verificationType; + + /// 数据范围:留空仅本机,MERCHANT 按终端所属商户查询全部设备。 + final String? scope; + + factory VerificationRecordQueryRequest.fromJson(Map json) => + _$VerificationRecordQueryRequestFromJson(json); + + Map toJson() => _$VerificationRecordQueryRequestToJson(this); +} + +/// 终端交易记录的服务端查询参数,数据范围由服务端按设备归属解析。 +@JsonSerializable(includeIfNull: false) +class TerminalOrderRecordQueryRequest { + const TerminalOrderRecordQueryRequest({ + required this.hardId, + required this.startDate, + required this.endDate, + this.scope, + this.orderStatusCodes, + this.hourType, + }); + + final int hardId; + final String startDate; + final String endDate; + + /// 数据范围:留空仅本机,MERCHANT 按终端所属商户查询全部设备。 + final String? scope; + + /// 订单状态编码集合,留空查询全部状态。 + final List? orderStatusCodes; + + /// 用餐时段编码,留空或 -1 不区分。 + final String? hourType; + + factory TerminalOrderRecordQueryRequest.fromJson( + Map json) => + _$TerminalOrderRecordQueryRequestFromJson(json); + + Map toJson() => + _$TerminalOrderRecordQueryRequestToJson(this); +} + +/// 交易记录远端行只承载列表展示字段,金额与时间口径和本地订单一致。 +@JsonSerializable() +class ServiceTerminalOrderRecord { + const ServiceTerminalOrderRecord({ + required this.orderId, + required this.orderNo, + required this.orderType, + required this.orderTypeName, + required this.orderStatus, + required this.orderStatusName, + required this.payType, + required this.payTypeName, + required this.username, + required this.productDesc, + required this.totalPrice, + required this.extraPrice, + required this.payPrice, + this.userDeptName, + this.hourType, + this.hardName, + this.createTime, + }); + + /// 后端 Long 为避免跨端精度丢失可能序列化为字符串,终端同时兼容数字返回。 + @JsonKey(fromJson: JsonTool.anyToInt) + final int orderId; + final String orderNo; + final String orderType; + final String orderTypeName; + final String orderStatus; + final String orderStatusName; + final String payType; + final String payTypeName; + final String username; + final String? userDeptName; + final String productDesc; + final String? hourType; + + /// 完成交易的设备名称;全部数据范围下用于区分不同机器。 + final String? hardName; + + /// 金额仍按整数分使用,同时兼容部分环境将数值序列化为字符串。 + @JsonKey(fromJson: JsonTool.anyToInt) + final int totalPrice; + @JsonKey(fromJson: JsonTool.anyToInt) + final int extraPrice; + @JsonKey(fromJson: JsonTool.anyToInt) + final int payPrice; + final DateTime? createTime; + + factory ServiceTerminalOrderRecord.fromJson(Map json) => + _$ServiceTerminalOrderRecordFromJson(json); + + Map toJson() => _$ServiceTerminalOrderRecordToJson(this); +} + +@JsonSerializable() +class ServiceTerminalOrderRecordPage extends ServicePageBase { + ServiceTerminalOrderRecordPage({this.records}); + + final List? records; + + factory ServiceTerminalOrderRecordPage.fromJson( + Map json) => + _$ServiceTerminalOrderRecordPageFromJson(json); + + @override + Map toJson() => _$ServiceTerminalOrderRecordPageToJson(this); +} + +/// 核销记录只承载列表展示字段,不复用本地订单实体,避免混淆远端与本地状态。 +@JsonSerializable() +class ServiceVerificationRecord { + const ServiceVerificationRecord({ + required this.orderId, + required this.orderNo, + required this.verificationType, + required this.verificationTypeName, + required this.username, + required this.productDesc, + required this.payTypeName, + required this.totalPrice, + required this.verificationTime, + this.userDeptName, + }); + + /// 后端 Long 为避免跨端精度丢失可能序列化为字符串,终端同时兼容数字返回。 + @JsonKey(fromJson: JsonTool.anyToInt) + final int orderId; + final String orderNo; + final String verificationType; + final String verificationTypeName; + final String username; + final String? userDeptName; + final String productDesc; + final String payTypeName; + + /// 金额仍按整数分使用,同时兼容部分环境将数值序列化为字符串。 + @JsonKey(fromJson: JsonTool.anyToInt) + final int totalPrice; + final DateTime verificationTime; + + factory ServiceVerificationRecord.fromJson(Map json) => + _$ServiceVerificationRecordFromJson(json); + + Map toJson() => _$ServiceVerificationRecordToJson(this); +} + +@JsonSerializable() +class ServiceVerificationRecordPage extends ServicePageBase { + ServiceVerificationRecordPage({this.records}); + + final List? records; + + factory ServiceVerificationRecordPage.fromJson(Map json) => + _$ServiceVerificationRecordPageFromJson(json); + + @override + Map toJson() => _$ServiceVerificationRecordPageToJson(this); +} + @JsonSerializable() class ServiceOpenCard { /// 用户名 @@ -520,3 +1098,319 @@ class ServiceHardCreateHardQrCodeScanOrder { Map toJson() => _$ServiceHardCreateHardQrCodeScanOrderToJson(this); } + +/// 收银终端支付宝、微信聚合订单码创建结果。 +/// +/// 该模型只接收 `AGGREGATE_ORDER_PAY` HTTPS 收银地址,不承载自有钱包码或平台原生码。 +@JsonSerializable() +class ServiceHardAggregateOrderCodePay { + /// Hardware 业务订单 ID,状态查询必须与 [aggregateNo] 一起提交。 + final String orderId; + + /// 当前终端绑定的收款商户 ID。 + final String mchId; + + /// Thirdparty 服务生成并持久化绑定到 Hardware 订单的聚合会话编号。 + final String aggregateNo; + + /// 支付宝和微信均可扫码访问的 HTTPS 聚合收银地址。 + final String qrCode; + + /// 二维码协议类型,服务端固定返回 `AGGREGATE_ORDER_PAY`。 + final String qrCodeType; + + /// 服务端确定的聚合二维码失效时间。 + final DateTime expireTime; + + /// 创建不可变的聚合订单码结果。 + const ServiceHardAggregateOrderCodePay({ + required this.orderId, + required this.mchId, + required this.aggregateNo, + required this.qrCode, + required this.qrCodeType, + required this.expireTime, + }); + + /// 将 Hardware 接口 JSON 转换为聚合订单码结果。 + factory ServiceHardAggregateOrderCodePay.fromJson( + Map json, + ) => + _$ServiceHardAggregateOrderCodePayFromJson(json); + + /// 将聚合订单码结果转换为 JSON,供测试和跨 Engine 消息核对使用。 + Map toJson() => + _$ServiceHardAggregateOrderCodePayToJson(this); +} + +/// Hardware 聚合订单码状态查询结果。 +/// +/// [paySuccess] 为 `true` 时,服务端已在订单锁内完成 Hardware 订单和库存更新。 +@JsonSerializable() +class ServiceHardAggregateOrderCodePayStatus { + /// Hardware 业务订单 ID。 + final String orderId; + + /// 与 Hardware 订单持久化绑定的聚合会话编号。 + final String aggregateNo; + + /// 聚合会话状态编码:10 等待、15 创建中、20 支付中、30 成功、40 关闭、50 过期、60 创建失败。 + final String status; + + /// 首个有效扫码选定的平台,尚未扫码时为空。 + final String? selectedPlatform; + + /// 支付宝或微信实际子订单号,尚未创建时为空。 + final String? payNo; + + /// 是否已经由支付平台确认成功并同步 Hardware 业务订单。 + final bool paySuccess; + + /// 聚合二维码服务端失效时间;已由本地成功态短路返回时允许为空。 + final DateTime? expireTime; + + /// Hardware 业务订单当前状态编码。 + final String orderStatus; + + /// 创建不可变的聚合支付状态。 + const ServiceHardAggregateOrderCodePayStatus({ + required this.orderId, + required this.aggregateNo, + required this.status, + this.selectedPlatform, + this.payNo, + required this.paySuccess, + this.expireTime, + required this.orderStatus, + }); + + /// 将 Hardware 接口 JSON 转换为聚合支付状态。 + factory ServiceHardAggregateOrderCodePayStatus.fromJson( + Map json, + ) => + _$ServiceHardAggregateOrderCodePayStatusFromJson(json); + + /// 将聚合支付状态转换为 JSON,供日志脱敏筛选和测试使用。 + Map toJson() => + _$ServiceHardAggregateOrderCodePayStatusToJson(this); +} + +/// 终端本人预约菜单查询参数。 +@JsonSerializable(includeIfNull: false) +class ReservationMenuQueryRequest { + const ReservationMenuQueryRequest({ + required this.hardId, + required this.recognitionType, + required this.maskName, + this.cardNumber, + this.faceUserId, + }); + + /// 当前终端设备 ID。 + final int hardId; + + /// 本人识别方式,沿用后端 CARD、FACE 契约。 + final String recognitionType; + + /// 是否由服务端脱敏用户姓名。 + final bool maskName; + + /// 饭卡卡号,仅 CARD 识别时传递。 + final String? cardNumber; + + /// 人脸插件识别出的用户 ID,仅 FACE 识别时传递。 + final int? faceUserId; + + factory ReservationMenuQueryRequest.fromJson(Map json) => + _$ReservationMenuQueryRequestFromJson(json); + + Map toJson() => _$ReservationMenuQueryRequestToJson(this); +} + +/// 终端识别本人后返回的预约菜单,姓名由服务端按终端配置决定是否脱敏。 +@JsonSerializable(explicitToJson: true) +class ServiceReservationMenuQuery { + /// 服务端按终端配置返回的用户姓名;字段名保留用于兼容既有协议。 + final String maskedName; + + /// 用户所属部门名称。 + final String? deptName; + + /// 当前终端绑定的食堂名称。 + final String merchantName; + + /// 查询结果中包含预约的日期数量。 + final int totalDayCount; + + /// 查询结果中包含预约的餐段数量。 + final int totalMealCount; + + /// 按预约日期升序排列的菜单列表。 + final List days; + + const ServiceReservationMenuQuery({ + required this.maskedName, + this.deptName, + required this.merchantName, + required this.totalDayCount, + required this.totalMealCount, + required this.days, + }); + + factory ServiceReservationMenuQuery.fromJson(Map json) => + _$ServiceReservationMenuQueryFromJson(json); + + Map toJson() => _$ServiceReservationMenuQueryToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class ServiceReservationDay { + /// 预约用餐日期,格式为 yyyy-MM-dd。 + final String date; + + /// 当天按餐段顺序排列的预约菜单。 + final List meals; + + const ServiceReservationDay({ + required this.date, + required this.meals, + }); + + factory ServiceReservationDay.fromJson(Map json) => + _$ServiceReservationDayFromJson(json); + + Map toJson() => _$ServiceReservationDayToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class ServiceReservationMeal { + /// 预约餐段编码,沿用硬件订单餐段契约。 + final String hourType; + + /// 预约餐段显示名称。 + final String mealTitle; + + /// 当前餐段所有预约订单的去重餐品汇总,用于兼容旧服务端和旧终端。 + final List items; + + /// 当前餐段可核销的预约订单 ID 列表。 + @JsonKey(fromJson: JsonTool.anyListToIntList) + final List orderIds; + + /// 当前餐段内每笔预约订单与餐品的明确对应关系。 + final List orders; + + const ServiceReservationMeal({ + required this.hourType, + required this.mealTitle, + required this.items, + this.orderIds = const [], + this.orders = const [], + }); + + factory ServiceReservationMeal.fromJson(Map json) => + _$ServiceReservationMealFromJson(json); + + Map toJson() => _$ServiceReservationMealToJson(this); +} + +/// 同一餐段内单笔预约与餐品的对应关系;旧服务端没有该字段时保持空列表。 +@JsonSerializable(explicitToJson: true) +class ServiceReservationOrder { + /// 可核销的预约订单 ID。 + @JsonKey(fromJson: JsonTool.anyToInt) + final int orderId; + + /// 该笔预约订单实际购买的商品与数量明细。 + final List items; + + const ServiceReservationOrder({ + required this.orderId, + required this.items, + }); + + factory ServiceReservationOrder.fromJson(Map json) => + _$ServiceReservationOrderFromJson(json); + + Map toJson() => _$ServiceReservationOrderToJson(this); +} + +/// 单笔预约订单中的商品与购买数量。 +@JsonSerializable(explicitToJson: true) +class ServiceReservationOrderItem { + /// 订单中的菜品库商品 SPU ID;旧服务端未返回时为空。 + @JsonKey(fromJson: JsonTool.anyToNullableInt) + final int? productId; + + /// 订单中的商品名称。 + final String productName; + + /// 订单中的商品图片地址。 + final String? productPic; + + /// 订单中的商品购买数量;历史异常数据缺失时允许为空。 + final int? productCount; + + /// 商品类型:1 为单品,2 为套餐;旧服务端缺失时按单品处理。 + final int spuType; + + /// 套餐内的组成菜品;单品或旧服务端返回时为空列表。 + final List comboItems; + + /// 创建服务端预约订单商品;新字段均提供兼容旧服务端的默认值。 + const ServiceReservationOrderItem({ + this.productId, + required this.productName, + this.productPic, + this.productCount, + this.spuType = 1, + this.comboItems = const [], + }); + + /// 从预约菜单接口 JSON 创建订单商品。 + factory ServiceReservationOrderItem.fromJson(Map json) => + _$ServiceReservationOrderItemFromJson(json); + + /// 将订单商品转换为可传输 JSON。 + Map toJson() => _$ServiceReservationOrderItemToJson(this); +} + +/// 预约订单中的套餐组成菜品,只承担服务端 JSON 与终端展示模型之间的传输。 +@JsonSerializable() +class ServiceReservationComboItem { + /// 组成菜品 SPU ID;菜品已删除或历史数据缺失时为空。 + @JsonKey(fromJson: JsonTool.anyToNullableInt) + final int? childSpuId; + + /// 组成菜品名称;关联菜品缺失时为空。 + final String? name; + + /// 组成菜品封面地址;未配置时为空。 + final String? picUrl; + + /// 套餐内组成菜品的份数;异常或缺失时由页面兜底为 1。 + final int? quantity; + + /// 套餐组成显示顺序;缺失时按接口返回顺序保留。 + final int? sortOrder; + + /// 组成菜品展示单价,单位为分;缺失时不展示价格。 + final int? price; + + /// 创建服务端套餐组成传输模型,缺失字段保持为空并交由页面模型规范化。 + const ServiceReservationComboItem({ + this.childSpuId, + this.name, + this.picUrl, + this.quantity, + this.sortOrder, + this.price, + }); + + /// 从预约菜单接口 JSON 创建套餐组成。 + factory ServiceReservationComboItem.fromJson(Map json) => + _$ServiceReservationComboItemFromJson(json); + + /// 将套餐组成转换为可传输 JSON。 + Map toJson() => _$ServiceReservationComboItemToJson(this); +} diff --git a/lib/common/models/api/hard_api_models.g.dart b/lib/common/models/api/hard_api_models.g.dart index b5dbd3d..63f7735 100644 --- a/lib/common/models/api/hard_api_models.g.dart +++ b/lib/common/models/api/hard_api_models.g.dart @@ -6,6 +6,196 @@ part of 'hard_api_models.dart'; // JsonSerializableGenerator // ************************************************************************** +OrderEvidenceRequest _$OrderEvidenceRequestFromJson( + Map json) => + OrderEvidenceRequest( + androidOrderCode: json['androidOrderCode'] as String?, + orderId: json['orderId'] as String?, + evidenceType: json['evidenceType'] as String, + evidenceRole: json['evidenceRole'] as String, + sequenceNo: (json['sequenceNo'] as num).toInt(), + userId: json['userId'] as String, + faceSampleId: json['faceSampleId'] as String?, + referenceImageUrl: json['referenceImageUrl'] as String?, + referenceImageVersion: json['referenceImageVersion'] == null + ? null + : DateTime.parse(json['referenceImageVersion'] as String), + similarity: (json['similarity'] as num?)?.toDouble(), + similarityThreshold: (json['similarityThreshold'] as num?)?.toDouble(), + capturedAt: DateTime.parse(json['capturedAt'] as String), + imageHash: json['imageHash'] as String?, + imageSize: (json['imageSize'] as num?)?.toInt(), + imageWidth: (json['imageWidth'] as num?)?.toInt(), + imageHeight: (json['imageHeight'] as num?)?.toInt(), + status: json['status'] as String?, + failureReason: json['failureReason'] as String?, + appVersion: json['appVersion'] as String, + ); + +Map _$OrderEvidenceRequestToJson( + OrderEvidenceRequest instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('androidOrderCode', instance.androidOrderCode); + writeNotNull('orderId', instance.orderId); + val['evidenceType'] = instance.evidenceType; + val['evidenceRole'] = instance.evidenceRole; + val['sequenceNo'] = instance.sequenceNo; + val['userId'] = instance.userId; + writeNotNull('faceSampleId', instance.faceSampleId); + writeNotNull('referenceImageUrl', instance.referenceImageUrl); + writeNotNull('referenceImageVersion', + instance.referenceImageVersion?.toIso8601String()); + writeNotNull('similarity', instance.similarity); + writeNotNull('similarityThreshold', instance.similarityThreshold); + val['capturedAt'] = instance.capturedAt.toIso8601String(); + writeNotNull('imageHash', instance.imageHash); + writeNotNull('imageSize', instance.imageSize); + writeNotNull('imageWidth', instance.imageWidth); + writeNotNull('imageHeight', instance.imageHeight); + writeNotNull('status', instance.status); + writeNotNull('failureReason', instance.failureReason); + val['appVersion'] = instance.appVersion; + return val; +} + +ServiceOrderEvidenceImage _$ServiceOrderEvidenceImageFromJson( + Map json) => + ServiceOrderEvidenceImage( + id: JsonTool.anyToRequiredString(json['id']), + evidenceType: json['evidenceType'] as String, + evidenceTypeName: json['evidenceTypeName'] as String?, + evidenceRole: json['evidenceRole'] as String, + evidenceRoleName: json['evidenceRoleName'] as String?, + sequenceNo: (json['sequenceNo'] as num).toInt(), + status: json['status'] as String, + statusName: json['statusName'] as String?, + similarity: JsonTool.anyToNullableDouble(json['similarity']), + similarityThreshold: + JsonTool.anyToNullableDouble(json['similarityThreshold']), + hardId: JsonTool.anyToString(json['hardId']), + capturedAt: json['capturedAt'] == null + ? null + : DateTime.parse(json['capturedAt'] as String), + imageHash: json['imageHash'] as String?, + imageUrl: json['imageUrl'] as String?, + failureReason: json['failureReason'] as String?, + ); + +Map _$ServiceOrderEvidenceImageToJson( + ServiceOrderEvidenceImage instance) => + { + 'id': instance.id, + 'evidenceType': instance.evidenceType, + 'evidenceTypeName': instance.evidenceTypeName, + 'evidenceRole': instance.evidenceRole, + 'evidenceRoleName': instance.evidenceRoleName, + 'sequenceNo': instance.sequenceNo, + 'status': instance.status, + 'statusName': instance.statusName, + 'similarity': instance.similarity, + 'similarityThreshold': instance.similarityThreshold, + 'hardId': instance.hardId, + 'capturedAt': instance.capturedAt?.toIso8601String(), + 'imageHash': instance.imageHash, + 'imageUrl': instance.imageUrl, + 'failureReason': instance.failureReason, + }; + +ServicePrintRecord _$ServicePrintRecordFromJson(Map json) => + ServicePrintRecord( + id: JsonTool.anyToRequiredString(json['id']), + orderId: JsonTool.anyToRequiredString(json['orderId']), + hardId: JsonTool.anyToString(json['hardId']), + hardName: json['hardName'] as String?, + printerHardId: JsonTool.anyToString(json['printerHardId']), + printerHardName: json['printerHardName'] as String?, + printType: json['printType'] as String? ?? '1', + printNo: (json['printNo'] as num?)?.toInt() ?? 1, + retryVersion: (json['retryVersion'] as num?)?.toInt() ?? 0, + attemptCount: (json['attemptCount'] as num?)?.toInt() ?? 0, + status: json['status'] as String? ?? '', + failureStage: json['failureStage'] as String?, + lastError: json['lastError'] as String?, + printedAt: json['printedAt'] == null + ? null + : DateTime.parse(json['printedAt'] as String), + reprintReason: json['reprintReason'] as String?, + pic: json['pic'] as String?, + createTime: json['createTime'] == null + ? null + : DateTime.parse(json['createTime'] as String), + ); + +Map _$ServicePrintRecordToJson(ServicePrintRecord instance) => + { + 'id': instance.id, + 'orderId': instance.orderId, + 'hardId': instance.hardId, + 'hardName': instance.hardName, + 'printerHardId': instance.printerHardId, + 'printerHardName': instance.printerHardName, + 'printType': instance.printType, + 'printNo': instance.printNo, + 'retryVersion': instance.retryVersion, + 'attemptCount': instance.attemptCount, + 'status': instance.status, + 'failureStage': instance.failureStage, + 'lastError': instance.lastError, + 'printedAt': instance.printedAt?.toIso8601String(), + 'reprintReason': instance.reprintReason, + 'pic': instance.pic, + 'createTime': instance.createTime?.toIso8601String(), + }; + +ServiceOrderEvidenceResolve _$ServiceOrderEvidenceResolveFromJson( + Map json) => + ServiceOrderEvidenceResolve( + orderId: JsonTool.anyToRequiredString(json['orderId']), + orderStatus: json['orderStatus'] as String, + evidenceImages: (json['evidenceImages'] as List?) + ?.map((e) => + ServiceOrderEvidenceImage.fromJson(e as Map)) + .toList() ?? + const [], + ); + +Map _$ServiceOrderEvidenceResolveToJson( + ServiceOrderEvidenceResolve instance) => + { + 'orderId': instance.orderId, + 'orderStatus': instance.orderStatus, + 'evidenceImages': instance.evidenceImages, + }; + +ServiceOrderEvidenceUploadResult _$ServiceOrderEvidenceUploadResultFromJson( + Map json) => + ServiceOrderEvidenceUploadResult( + evidenceId: JsonTool.anyToRequiredString(json['evidenceId']), + orderId: JsonTool.anyToRequiredString(json['orderId']), + status: json['status'] as String, + uploaded: json['uploaded'] as bool, + message: json['message'] as String, + imageUrl: json['imageUrl'] as String?, + ); + +Map _$ServiceOrderEvidenceUploadResultToJson( + ServiceOrderEvidenceUploadResult instance) => + { + 'evidenceId': instance.evidenceId, + 'orderId': instance.orderId, + 'status': instance.status, + 'uploaded': instance.uploaded, + 'message': instance.message, + 'imageUrl': instance.imageUrl, + }; + ServiceHardware _$ServiceHardwareFromJson(Map json) => ServiceHardware( id: json['id'] as String?, @@ -59,13 +249,15 @@ Map _$ServiceHardwareToJson(ServiceHardware instance) => }; ServiceCar _$ServiceCarFromJson(Map json) => ServiceCar( - id: json['id'] as String?, - parentCarId: json['parentCarId'] as String?, + id: JsonTool.anyToString(json['id']), + parentCarId: JsonTool.anyToString(json['parentCarId']), productName: json['productName'] as String?, productPrice: (json['productPrice'] as num?)?.toInt(), - productId: json['productId'] as String?, + productId: JsonTool.anyToString(json['productId']), productCount: (json['productCount'] as num?)?.toInt(), - totalPrice: JsonTool.stringToInt(json['totalPrice'] as String?), + productNature: json['productNature'] as String?, + productWeight: JsonTool.anyToNullableDouble(json['productWeight']), + totalPrice: JsonTool.anyToNullableInt(json['totalPrice']), productPic: json['productPic'] as String?, ); @@ -77,6 +269,8 @@ Map _$ServiceCarToJson(ServiceCar instance) => 'productPrice': instance.productPrice, 'productId': instance.productId, 'productCount': instance.productCount, + 'productNature': instance.productNature, + 'productWeight': instance.productWeight, 'totalPrice': JsonTool.intToString(instance.totalPrice), 'productPic': instance.productPic, }; @@ -121,18 +315,33 @@ Map _$ServiceQRCodeToJson(ServiceQRCode instance) => ServiceHardOrder _$ServiceHardOrderFromJson(Map json) => ServiceHardOrder( - userId: json['userId'] as String?, + id: JsonTool.anyToString(json['id']), + orderNo: json['orderNo'] as String?, + userId: JsonTool.anyToString(json['userId']), username: json['username'] as String?, userMobile: json['userMobile'] as String?, userAvatar: json['userAvatar'] as String?, orderType: json['orderType'] as String?, orderStatus: json['orderStatus'] as String?, payType: json['payType'] as String?, - walletId: json['walletId'] as String?, + walletId: JsonTool.anyToString(json['walletId']), + hourType: json['hourType'] as String?, + apptHourType: json['apptHourType'] as String?, + productDesc: json['productDesc'] as String?, + userDeptName: json['userDeptName'] as String?, + totalPrice: JsonTool.anyToNullableInt(json['totalPrice']), + extraPrice: JsonTool.anyToNullableInt(json['extraPrice']), + payPrice: JsonTool.anyToNullableInt(json['payPrice']), + androidOrderCode: json['androidOrderCode'] as String?, + createTime: json['createTime'] == null + ? null + : DateTime.parse(json['createTime'] as String), ); Map _$ServiceHardOrderToJson(ServiceHardOrder instance) => { + 'id': instance.id, + 'orderNo': instance.orderNo, 'userId': instance.userId, 'username': instance.username, 'userMobile': instance.userMobile, @@ -141,6 +350,199 @@ Map _$ServiceHardOrderToJson(ServiceHardOrder instance) => 'orderStatus': instance.orderStatus, 'payType': instance.payType, 'walletId': instance.walletId, + 'hourType': instance.hourType, + 'apptHourType': instance.apptHourType, + 'productDesc': instance.productDesc, + 'userDeptName': instance.userDeptName, + 'totalPrice': instance.totalPrice, + 'extraPrice': instance.extraPrice, + 'payPrice': instance.payPrice, + 'androidOrderCode': instance.androidOrderCode, + 'createTime': instance.createTime?.toIso8601String(), + }; + +VerificationRecordQueryRequest _$VerificationRecordQueryRequestFromJson( + Map json) => + VerificationRecordQueryRequest( + hardId: (json['hardId'] as num).toInt(), + startDate: json['startDate'] as String, + endDate: json['endDate'] as String, + verificationType: json['verificationType'] as String?, + scope: json['scope'] as String?, + ); + +Map _$VerificationRecordQueryRequestToJson( + VerificationRecordQueryRequest instance) { + final val = { + 'hardId': instance.hardId, + 'startDate': instance.startDate, + 'endDate': instance.endDate, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('verificationType', instance.verificationType); + writeNotNull('scope', instance.scope); + return val; +} + +TerminalOrderRecordQueryRequest _$TerminalOrderRecordQueryRequestFromJson( + Map json) => + TerminalOrderRecordQueryRequest( + hardId: (json['hardId'] as num).toInt(), + startDate: json['startDate'] as String, + endDate: json['endDate'] as String, + scope: json['scope'] as String?, + orderStatusCodes: (json['orderStatusCodes'] as List?) + ?.map((e) => e as String) + .toList(), + hourType: json['hourType'] as String?, + ); + +Map _$TerminalOrderRecordQueryRequestToJson( + TerminalOrderRecordQueryRequest instance) { + final val = { + 'hardId': instance.hardId, + 'startDate': instance.startDate, + 'endDate': instance.endDate, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('scope', instance.scope); + writeNotNull('orderStatusCodes', instance.orderStatusCodes); + writeNotNull('hourType', instance.hourType); + return val; +} + +ServiceTerminalOrderRecord _$ServiceTerminalOrderRecordFromJson( + Map json) => + ServiceTerminalOrderRecord( + orderId: JsonTool.anyToInt(json['orderId']), + orderNo: json['orderNo'] as String, + orderType: json['orderType'] as String, + orderTypeName: json['orderTypeName'] as String, + orderStatus: json['orderStatus'] as String, + orderStatusName: json['orderStatusName'] as String, + payType: json['payType'] as String, + payTypeName: json['payTypeName'] as String, + username: json['username'] as String, + productDesc: json['productDesc'] as String, + totalPrice: JsonTool.anyToInt(json['totalPrice']), + extraPrice: JsonTool.anyToInt(json['extraPrice']), + payPrice: JsonTool.anyToInt(json['payPrice']), + userDeptName: json['userDeptName'] as String?, + hourType: json['hourType'] as String?, + hardName: json['hardName'] as String?, + createTime: json['createTime'] == null + ? null + : DateTime.parse(json['createTime'] as String), + ); + +Map _$ServiceTerminalOrderRecordToJson( + ServiceTerminalOrderRecord instance) => + { + 'orderId': instance.orderId, + 'orderNo': instance.orderNo, + 'orderType': instance.orderType, + 'orderTypeName': instance.orderTypeName, + 'orderStatus': instance.orderStatus, + 'orderStatusName': instance.orderStatusName, + 'payType': instance.payType, + 'payTypeName': instance.payTypeName, + 'username': instance.username, + 'userDeptName': instance.userDeptName, + 'productDesc': instance.productDesc, + 'hourType': instance.hourType, + 'hardName': instance.hardName, + 'totalPrice': instance.totalPrice, + 'extraPrice': instance.extraPrice, + 'payPrice': instance.payPrice, + 'createTime': instance.createTime?.toIso8601String(), + }; + +ServiceTerminalOrderRecordPage _$ServiceTerminalOrderRecordPageFromJson( + Map json) => + ServiceTerminalOrderRecordPage( + records: (json['records'] as List?) + ?.map((e) => + ServiceTerminalOrderRecord.fromJson(e as Map)) + .toList(), + ) + ..current = (json['current'] as num).toInt() + ..pages = (json['pages'] as num).toInt() + ..size = (json['size'] as num).toInt() + ..total = (json['total'] as num).toInt(); + +Map _$ServiceTerminalOrderRecordPageToJson( + ServiceTerminalOrderRecordPage instance) => + { + 'current': instance.current, + 'pages': instance.pages, + 'size': instance.size, + 'total': instance.total, + 'records': instance.records, + }; + +ServiceVerificationRecord _$ServiceVerificationRecordFromJson( + Map json) => + ServiceVerificationRecord( + orderId: JsonTool.anyToInt(json['orderId']), + orderNo: json['orderNo'] as String, + verificationType: json['verificationType'] as String, + verificationTypeName: json['verificationTypeName'] as String, + username: json['username'] as String, + productDesc: json['productDesc'] as String, + payTypeName: json['payTypeName'] as String, + totalPrice: JsonTool.anyToInt(json['totalPrice']), + verificationTime: DateTime.parse(json['verificationTime'] as String), + userDeptName: json['userDeptName'] as String?, + ); + +Map _$ServiceVerificationRecordToJson( + ServiceVerificationRecord instance) => + { + 'orderId': instance.orderId, + 'orderNo': instance.orderNo, + 'verificationType': instance.verificationType, + 'verificationTypeName': instance.verificationTypeName, + 'username': instance.username, + 'userDeptName': instance.userDeptName, + 'productDesc': instance.productDesc, + 'payTypeName': instance.payTypeName, + 'totalPrice': instance.totalPrice, + 'verificationTime': instance.verificationTime.toIso8601String(), + }; + +ServiceVerificationRecordPage _$ServiceVerificationRecordPageFromJson( + Map json) => + ServiceVerificationRecordPage( + records: (json['records'] as List?) + ?.map((e) => + ServiceVerificationRecord.fromJson(e as Map)) + .toList(), + ) + ..current = (json['current'] as num).toInt() + ..pages = (json['pages'] as num).toInt() + ..size = (json['size'] as num).toInt() + ..total = (json['total'] as num).toInt(); + +Map _$ServiceVerificationRecordPageToJson( + ServiceVerificationRecordPage instance) => + { + 'current': instance.current, + 'pages': instance.pages, + 'size': instance.size, + 'total': instance.total, + 'records': instance.records, }; ServiceOpenCard _$ServiceOpenCardFromJson(Map json) => @@ -336,3 +738,215 @@ Map _$ServiceHardCreateHardQrCodeScanOrderToJson( 'mchId': instance.mchId, 'qrCodeData': instance.qrCodeData, }; + +ServiceHardAggregateOrderCodePay _$ServiceHardAggregateOrderCodePayFromJson( + Map json) => + ServiceHardAggregateOrderCodePay( + orderId: json['orderId'] as String, + mchId: json['mchId'] as String, + aggregateNo: json['aggregateNo'] as String, + qrCode: json['qrCode'] as String, + qrCodeType: json['qrCodeType'] as String, + expireTime: DateTime.parse(json['expireTime'] as String), + ); + +Map _$ServiceHardAggregateOrderCodePayToJson( + ServiceHardAggregateOrderCodePay instance) => + { + 'orderId': instance.orderId, + 'mchId': instance.mchId, + 'aggregateNo': instance.aggregateNo, + 'qrCode': instance.qrCode, + 'qrCodeType': instance.qrCodeType, + 'expireTime': instance.expireTime.toIso8601String(), + }; + +ServiceHardAggregateOrderCodePayStatus + _$ServiceHardAggregateOrderCodePayStatusFromJson( + Map json) => + ServiceHardAggregateOrderCodePayStatus( + orderId: json['orderId'] as String, + aggregateNo: json['aggregateNo'] as String, + status: json['status'] as String, + selectedPlatform: json['selectedPlatform'] as String?, + payNo: json['payNo'] as String?, + paySuccess: json['paySuccess'] as bool, + expireTime: json['expireTime'] == null + ? null + : DateTime.parse(json['expireTime'] as String), + orderStatus: json['orderStatus'] as String, + ); + +Map _$ServiceHardAggregateOrderCodePayStatusToJson( + ServiceHardAggregateOrderCodePayStatus instance) => + { + 'orderId': instance.orderId, + 'aggregateNo': instance.aggregateNo, + 'status': instance.status, + 'selectedPlatform': instance.selectedPlatform, + 'payNo': instance.payNo, + 'paySuccess': instance.paySuccess, + 'expireTime': instance.expireTime?.toIso8601String(), + 'orderStatus': instance.orderStatus, + }; + +ReservationMenuQueryRequest _$ReservationMenuQueryRequestFromJson( + Map json) => + ReservationMenuQueryRequest( + hardId: (json['hardId'] as num).toInt(), + recognitionType: json['recognitionType'] as String, + maskName: json['maskName'] as bool, + cardNumber: json['cardNumber'] as String?, + faceUserId: (json['faceUserId'] as num?)?.toInt(), + ); + +Map _$ReservationMenuQueryRequestToJson( + ReservationMenuQueryRequest instance) { + final val = { + 'hardId': instance.hardId, + 'recognitionType': instance.recognitionType, + 'maskName': instance.maskName, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('cardNumber', instance.cardNumber); + writeNotNull('faceUserId', instance.faceUserId); + return val; +} + +ServiceReservationMenuQuery _$ServiceReservationMenuQueryFromJson( + Map json) => + ServiceReservationMenuQuery( + maskedName: json['maskedName'] as String, + deptName: json['deptName'] as String?, + merchantName: json['merchantName'] as String, + totalDayCount: (json['totalDayCount'] as num).toInt(), + totalMealCount: (json['totalMealCount'] as num).toInt(), + days: (json['days'] as List) + .map((e) => ServiceReservationDay.fromJson(e as Map)) + .toList(), + ); + +Map _$ServiceReservationMenuQueryToJson( + ServiceReservationMenuQuery instance) => + { + 'maskedName': instance.maskedName, + 'deptName': instance.deptName, + 'merchantName': instance.merchantName, + 'totalDayCount': instance.totalDayCount, + 'totalMealCount': instance.totalMealCount, + 'days': instance.days.map((e) => e.toJson()).toList(), + }; + +ServiceReservationDay _$ServiceReservationDayFromJson( + Map json) => + ServiceReservationDay( + date: json['date'] as String, + meals: (json['meals'] as List) + .map( + (e) => ServiceReservationMeal.fromJson(e as Map)) + .toList(), + ); + +Map _$ServiceReservationDayToJson( + ServiceReservationDay instance) => + { + 'date': instance.date, + 'meals': instance.meals.map((e) => e.toJson()).toList(), + }; + +ServiceReservationMeal _$ServiceReservationMealFromJson( + Map json) => + ServiceReservationMeal( + hourType: json['hourType'] as String, + mealTitle: json['mealTitle'] as String, + items: (json['items'] as List).map((e) => e as String).toList(), + orderIds: json['orderIds'] == null + ? const [] + : JsonTool.anyListToIntList(json['orderIds']), + orders: (json['orders'] as List?) + ?.map((e) => + ServiceReservationOrder.fromJson(e as Map)) + .toList() ?? + const [], + ); + +Map _$ServiceReservationMealToJson( + ServiceReservationMeal instance) => + { + 'hourType': instance.hourType, + 'mealTitle': instance.mealTitle, + 'items': instance.items, + 'orderIds': instance.orderIds, + 'orders': instance.orders.map((e) => e.toJson()).toList(), + }; + +ServiceReservationOrder _$ServiceReservationOrderFromJson( + Map json) => + ServiceReservationOrder( + orderId: JsonTool.anyToInt(json['orderId']), + items: (json['items'] as List) + .map((e) => + ServiceReservationOrderItem.fromJson(e as Map)) + .toList(), + ); + +Map _$ServiceReservationOrderToJson( + ServiceReservationOrder instance) => + { + 'orderId': instance.orderId, + 'items': instance.items.map((e) => e.toJson()).toList(), + }; + +ServiceReservationOrderItem _$ServiceReservationOrderItemFromJson( + Map json) => + ServiceReservationOrderItem( + productId: JsonTool.anyToNullableInt(json['productId']), + productName: json['productName'] as String, + productPic: json['productPic'] as String?, + productCount: (json['productCount'] as num?)?.toInt(), + spuType: (json['spuType'] as num?)?.toInt() ?? 1, + comboItems: (json['comboItems'] as List?) + ?.map((e) => ServiceReservationComboItem.fromJson( + e as Map)) + .toList() ?? + const [], + ); + +Map _$ServiceReservationOrderItemToJson( + ServiceReservationOrderItem instance) => + { + 'productId': instance.productId, + 'productName': instance.productName, + 'productPic': instance.productPic, + 'productCount': instance.productCount, + 'spuType': instance.spuType, + 'comboItems': instance.comboItems.map((e) => e.toJson()).toList(), + }; + +ServiceReservationComboItem _$ServiceReservationComboItemFromJson( + Map json) => + ServiceReservationComboItem( + childSpuId: JsonTool.anyToNullableInt(json['childSpuId']), + name: json['name'] as String?, + picUrl: json['picUrl'] as String?, + quantity: (json['quantity'] as num?)?.toInt(), + sortOrder: (json['sortOrder'] as num?)?.toInt(), + price: (json['price'] as num?)?.toInt(), + ); + +Map _$ServiceReservationComboItemToJson( + ServiceReservationComboItem instance) => + { + 'childSpuId': instance.childSpuId, + 'name': instance.name, + 'picUrl': instance.picUrl, + 'quantity': instance.quantity, + 'sortOrder': instance.sortOrder, + 'price': instance.price, + }; diff --git a/lib/common/models/api/user_api_models.dart b/lib/common/models/api/user_api_models.dart index 5fb3f51..afd4f1f 100644 --- a/lib/common/models/api/user_api_models.dart +++ b/lib/common/models/api/user_api_models.dart @@ -136,6 +136,225 @@ class ServiceAsyncFace { Map toJson() => _$ServiceAsyncFaceToJson(this); } +/// V3 部门人脸快照中的单条服务端权威记录。 +@JsonSerializable() +class ServiceFaceSyncRecord { + /// 人脸记录数据库主键,作为分页顺序和快照范围的稳定标识。 + @JsonKey( + fromJson: JsonTool.anyToRequiredString, + toJson: JsonTool.stringToInt, + ) + final String id; + + /// 虹软 32 位搜索标识,来源于服务端 autoCode。 + @JsonKey( + fromJson: JsonTool.anyToRequiredString, + toJson: JsonTool.stringToInt, + ) + final String sampleId; + + /// 人脸所属用户主键,MQ 增量按该字段回查同一用户的全部人脸。 + @JsonKey( + fromJson: JsonTool.anyToRequiredString, + toJson: JsonTool.stringToInt, + ) + final String userId; + + /// 用户姓名,用于本地识别结果和设置页展示。 + final String? username; + + /// 用户当前部门主键,保证终端只维护绑定部门及其子部门人脸。 + @JsonKey( + fromJson: JsonTool.anyToRequiredString, + toJson: JsonTool.stringToInt, + ) + final String deptId; + + /// 用户当前部门名称,用于本地展示。 + final String? deptName; + + /// 服务端人脸图片地址,地址变化会触发重新下载与特征提取。 + final String? faceImg; + + /// 服务端人脸图片版本时间;未修改的历史记录由服务端回退为创建时间。 + final DateTime? sourceUpdatedAt; + + /// 创建一条 V3 人脸快照记录。 + const ServiceFaceSyncRecord({ + required this.id, + required this.sampleId, + required this.userId, + this.username, + required this.deptId, + this.deptName, + this.faceImg, + this.sourceUpdatedAt, + }); + + /// 从统一网络层返回的 JSON 创建快照记录。 + factory ServiceFaceSyncRecord.fromJson(Map json) => + _$ServiceFaceSyncRecordFromJson(json); + + /// 将快照记录转换为 JSON,主要供模型测试和诊断使用。 + Map toJson() => _$ServiceFaceSyncRecordToJson(this); +} + +/// V3 部门人脸同步的固定上界游标页面。 +@JsonSerializable() +class ServiceFaceSyncSnapshot { + /// 当前页面记录,服务端保证按数据库主键严格升序返回。 + final List records; + + /// 下一页排他游标;末页仅用于终端完成连续性校验。 + @JsonKey( + fromJson: JsonTool.anyToRequiredString, + toJson: JsonTool.stringToInt, + ) + final String nextAfterId; + + /// 是否还有位于固定快照上界内的下一页。 + final bool hasMore; + + /// 首次请求固定的人脸数据库主键上界,后续请求必须原样携带。 + @JsonKey( + fromJson: JsonTool.anyToRequiredString, + toJson: JsonTool.stringToInt, + ) + final String snapshotMaxId; + + /// 创建一个固定上界游标页面。 + const ServiceFaceSyncSnapshot({ + required this.records, + required this.nextAfterId, + required this.hasMore, + required this.snapshotMaxId, + }); + + /// 从统一网络层返回的 JSON 创建快照页面。 + factory ServiceFaceSyncSnapshot.fromJson(Map json) => + _$ServiceFaceSyncSnapshotFromJson(json); + + /// 将快照页面转换为 JSON,主要供模型测试使用。 + Map toJson() => _$ServiceFaceSyncSnapshotToJson(this); +} + +/// 终端批量删除服务端人脸后的幂等确认结果。 +@JsonSerializable() +class ServiceTerminalFaceDeleteResult { + /// 本次实际逻辑删除的人脸样本编号。 + final List deletedSampleIds; + + /// 请求前已经不存在的样本编号,本机可继续执行本地清理。 + final List alreadyAbsentSampleIds; + + /// 创建一份服务端删除确认结果。 + const ServiceTerminalFaceDeleteResult({ + required this.deletedSampleIds, + required this.alreadyAbsentSampleIds, + }); + + /// 从统一网络层返回的 JSON 创建删除结果。 + factory ServiceTerminalFaceDeleteResult.fromJson(Map json) => + _$ServiceTerminalFaceDeleteResultFromJson(json); + + /// 将删除结果转换为 JSON,主要供协议测试使用。 + Map toJson() => + _$ServiceTerminalFaceDeleteResultToJson(this); + + /// 服务端允许本机清理的全部样本编号。 + Set get confirmedSampleIds => { + ...deletedSampleIds, + ...alreadyAbsentSampleIds, + }; +} + +/// 终端提交给服务端的一条在库人脸静态图片质量结果。 +@JsonSerializable(createFactory: false) +class ServiceFaceQualityReportItem { + /// 创建一条符合 Hardware 批量上报协议的质量结果。 + const ServiceFaceQualityReportItem({ + required this.sampleId, + required this.status, + required this.sourceUpdatedAt, + required this.checkedAt, + this.score, + this.errorCode, + }); + + /// 虹软 32 位搜索样本编号,对应服务端人脸记录的 autoCode。 + final String sampleId; + + /// 检测状态,跨端协议值仅使用 detected 或 failed。 + final String status; + + /// 虹软静态图片质量分,detected 时范围为 0 到 1,failed 时为空。 + final double? score; + + /// 本次检测所依据的服务端人脸图片版本,来自 V3 快照 sourceUpdatedAt。 + @JsonKey(toJson: JsonTool.dateTimeToServiceString) + final DateTime sourceUpdatedAt; + + /// 终端完成本次检测的本地时间,用于阻止旧结果覆盖服务端新结果。 + @JsonKey(toJson: JsonTool.dateTimeToServiceString) + final DateTime checkedAt; + + /// 虹软 SDK 或终端插件错误码,检测成功时为空。 + final int? errorCode; + + /// 转换为统一 Dio 可直接发送的 JSON 请求条目。 + Map toJson() => _$ServiceFaceQualityReportItemToJson(this); +} + +/// 当前设备竞争商户部门人脸质量上报资格的结果。 +@JsonSerializable() +class ServiceFaceQualityLease { + /// 创建一份租约竞争结果。 + const ServiceFaceQualityLease({ + required this.granted, + required this.remainingSeconds, + }); + + /// 当前设备是否取得租约;为 false 时本轮不发送质量批次。 + final bool granted; + + /// 当前租约剩余秒数,仅用于终端诊断日志。 + final int remainingSeconds; + + /// 从 Hardware 返回的 JSON 创建租约结果。 + factory ServiceFaceQualityLease.fromJson(Map json) => + _$ServiceFaceQualityLeaseFromJson(json); + + /// 将租约结果转换为 JSON,主要供协议测试使用。 + Map toJson() => _$ServiceFaceQualityLeaseToJson(this); +} + +/// 服务端批量接收终端人脸质量后的幂等处理结果。 +@JsonSerializable() +class ServiceFaceQualityReportResult { + /// 创建一份服务端质量上报确认结果。 + const ServiceFaceQualityReportResult({ + required this.updatedSampleIds, + required this.ignoredStaleSampleIds, + required this.alreadyAbsentSampleIds, + }); + + /// 本次成功写入服务端的样本编号。 + final List updatedSampleIds; + + /// 因图片版本不一致、已有 detected 或检测时间较旧而忽略的样本编号。 + final List ignoredStaleSampleIds; + + /// 请求时服务端已经不存在的样本编号。 + final List alreadyAbsentSampleIds; + + /// 从统一网络层返回的 JSON 创建上报确认结果。 + factory ServiceFaceQualityReportResult.fromJson(Map json) => + _$ServiceFaceQualityReportResultFromJson(json); + + /// 将确认结果转换为 JSON,主要供协议测试使用。 + Map toJson() => _$ServiceFaceQualityReportResultToJson(this); +} + @JsonSerializable() class ServiceTenant { /// id diff --git a/lib/common/models/api/user_api_models.g.dart b/lib/common/models/api/user_api_models.g.dart index af81863..989b351 100644 --- a/lib/common/models/api/user_api_models.g.dart +++ b/lib/common/models/api/user_api_models.g.dart @@ -77,6 +77,120 @@ Map _$ServiceAsyncFaceToJson(ServiceAsyncFace instance) => 'delFlag': JsonTool.booleanToString(instance.delFlag), }; +ServiceFaceSyncRecord _$ServiceFaceSyncRecordFromJson( + Map json) => + ServiceFaceSyncRecord( + id: JsonTool.anyToRequiredString(json['id']), + sampleId: JsonTool.anyToRequiredString(json['sampleId']), + userId: JsonTool.anyToRequiredString(json['userId']), + username: json['username'] as String?, + deptId: JsonTool.anyToRequiredString(json['deptId']), + deptName: json['deptName'] as String?, + faceImg: json['faceImg'] as String?, + sourceUpdatedAt: json['sourceUpdatedAt'] == null + ? null + : DateTime.parse(json['sourceUpdatedAt'] as String), + ); + +Map _$ServiceFaceSyncRecordToJson( + ServiceFaceSyncRecord instance) => + { + 'id': JsonTool.stringToInt(instance.id), + 'sampleId': JsonTool.stringToInt(instance.sampleId), + 'userId': JsonTool.stringToInt(instance.userId), + 'username': instance.username, + 'deptId': JsonTool.stringToInt(instance.deptId), + 'deptName': instance.deptName, + 'faceImg': instance.faceImg, + 'sourceUpdatedAt': instance.sourceUpdatedAt?.toIso8601String(), + }; + +ServiceFaceSyncSnapshot _$ServiceFaceSyncSnapshotFromJson( + Map json) => + ServiceFaceSyncSnapshot( + records: (json['records'] as List) + .map((e) => ServiceFaceSyncRecord.fromJson(e as Map)) + .toList(), + nextAfterId: JsonTool.anyToRequiredString(json['nextAfterId']), + hasMore: json['hasMore'] as bool, + snapshotMaxId: JsonTool.anyToRequiredString(json['snapshotMaxId']), + ); + +Map _$ServiceFaceSyncSnapshotToJson( + ServiceFaceSyncSnapshot instance) => + { + 'records': instance.records, + 'nextAfterId': JsonTool.stringToInt(instance.nextAfterId), + 'hasMore': instance.hasMore, + 'snapshotMaxId': JsonTool.stringToInt(instance.snapshotMaxId), + }; + +ServiceTerminalFaceDeleteResult _$ServiceTerminalFaceDeleteResultFromJson( + Map json) => + ServiceTerminalFaceDeleteResult( + deletedSampleIds: (json['deletedSampleIds'] as List) + .map((e) => e as String) + .toList(), + alreadyAbsentSampleIds: (json['alreadyAbsentSampleIds'] as List) + .map((e) => e as String) + .toList(), + ); + +Map _$ServiceTerminalFaceDeleteResultToJson( + ServiceTerminalFaceDeleteResult instance) => + { + 'deletedSampleIds': instance.deletedSampleIds, + 'alreadyAbsentSampleIds': instance.alreadyAbsentSampleIds, + }; + +Map _$ServiceFaceQualityReportItemToJson( + ServiceFaceQualityReportItem instance) => + { + 'sampleId': instance.sampleId, + 'status': instance.status, + 'score': instance.score, + 'sourceUpdatedAt': + JsonTool.dateTimeToServiceString(instance.sourceUpdatedAt), + 'checkedAt': JsonTool.dateTimeToServiceString(instance.checkedAt), + 'errorCode': instance.errorCode, + }; + +ServiceFaceQualityLease _$ServiceFaceQualityLeaseFromJson( + Map json) => + ServiceFaceQualityLease( + granted: json['granted'] as bool, + remainingSeconds: (json['remainingSeconds'] as num).toInt(), + ); + +Map _$ServiceFaceQualityLeaseToJson( + ServiceFaceQualityLease instance) => + { + 'granted': instance.granted, + 'remainingSeconds': instance.remainingSeconds, + }; + +ServiceFaceQualityReportResult _$ServiceFaceQualityReportResultFromJson( + Map json) => + ServiceFaceQualityReportResult( + updatedSampleIds: (json['updatedSampleIds'] as List) + .map((e) => e as String) + .toList(), + ignoredStaleSampleIds: (json['ignoredStaleSampleIds'] as List) + .map((e) => e as String) + .toList(), + alreadyAbsentSampleIds: (json['alreadyAbsentSampleIds'] as List) + .map((e) => e as String) + .toList(), + ); + +Map _$ServiceFaceQualityReportResultToJson( + ServiceFaceQualityReportResult instance) => + { + 'updatedSampleIds': instance.updatedSampleIds, + 'ignoredStaleSampleIds': instance.ignoredStaleSampleIds, + 'alreadyAbsentSampleIds': instance.alreadyAbsentSampleIds, + }; + ServiceTenant _$ServiceTenantFromJson(Map json) => ServiceTenant( id: json['id'] as String?, diff --git a/lib/common/models/database/database_enum_models.dart b/lib/common/models/database/database_enum_models.dart index a10f161..4f9e1ef 100644 --- a/lib/common/models/database/database_enum_models.dart +++ b/lib/common/models/database/database_enum_models.dart @@ -36,31 +36,68 @@ enum OrderType implements BaseEnum { /// 付款方式 enum OrderPayType implements BaseEnum { + /// 默认钱包余额支付。 WALLET_DEFAULT("1", "普通钱包支付"), + + /// 用户向终端出示自有钱包二维码支付。 WALLET_QR_CODE("2", "钱包二维码支付"), + + /// 旧版终端展示自有钱包订单码,由移动端扫码支付。 WALLET_QR_CODE_SCAN("22", "钱包扫一扫支付"), + + /// 终端展示支付宝、微信共用的 HTTPS 聚合收银二维码。 + AGGREGATE_ORDER_PAY("23", "支付宝微信聚合码支付"), + + /// 实体饭卡钱包支付。 WALLET_CARD("3", "钱包饭卡支付"), + + /// 人脸识别钱包支付。 FACE("4", "人脸识别支付"), + + /// 跨单位业务二维码支付。 STRIDE_DEPT_QR_CODE("5", "跨单位二维码支付"), + + /// 外来人员票券二维码支付。 FOREIGN_TICKET_QR_CODE("6", "外来人员二维码支付"), + + /// 线上预约订单二维码核销支付。 RESERVE_QR_CODE("7", "线上订餐二维码支付"), + + /// 终端线下现金支付。 CASH("8", "现金支付"), + + /// 微信用户付款码支付。 WECHANT_SELF_QR_CODE_PAY("10", "微信付款码支付"), + + /// 支付宝用户付款码支付。 ALI_SELF_QR_CODE_PAY("11", "支付宝付款码支付"), + + /// 中医院渠道支付。 ZYY("12", "中医院"), + + /// 人民医院渠道支付。 RMYY("13", "人民医院"), + + /// 超市消费卡渠道支付。 CSXFK("13", "超市消费卡"), + + /// 活动订单核销。 ACT_CANCEL("50", "活动核销"), ; @override + + /// 数据库和服务端接口使用的稳定支付类型编码。 final String code; @override + + /// 支付方式的中文展示名称。 final String title; + /// 创建一个使用稳定协议编码的支付方式枚举值。 const OrderPayType(this.code, this.title); - // 是否是外部人员支付 + /// 判断是否为跨单位或外来人员二维码支付。 static bool isStrideOrForeign(OrderPayType payType) { return [ OrderPayType.STRIDE_DEPT_QR_CODE, @@ -68,7 +105,7 @@ enum OrderPayType implements BaseEnum { ].contains(payType); } - // 是否是自付款(微信、支付宝) + /// 判断是否为顾客直接使用微信或支付宝付款码支付。 static bool isExternalPay(OrderPayType payType) { return [ OrderPayType.WECHANT_SELF_QR_CODE_PAY, diff --git a/lib/common/models/database/database_models.dart b/lib/common/models/database/database_models.dart index 8d08398..575a38f 100644 --- a/lib/common/models/database/database_models.dart +++ b/lib/common/models/database/database_models.dart @@ -22,6 +22,30 @@ class SysSetting extends Equatable { /// 商户id String? serviceMerchantId; + /// 当前业务终端绑定的 EY_PRINT 逻辑打印机资产 ID;未绑定时为空。 + String? printerHardId; + + /// 当前业务终端绑定的 EY_PRINT 逻辑打印机资产名称;未绑定时为空。 + String? printerHardName; + + /// 当前业务终端代理的 USB 打印机型号,由服务端绑定配置下发。 + String? printerModel; + + /// 当前绑定打印机的 USB Vendor ID 十进制值;尚未上报时为空。 + int? printerUsbVendorId; + + /// 当前绑定打印机的 USB Product ID 十进制值;尚未上报时为空。 + int? printerUsbProductId; + + /// 当前绑定打印机的纸宽,单位毫米;旧配置缺失时默认 80。 + int printerPaperWidth; + + /// 当前绑定打印机是否在完整写入后发送自动切刀指令。 + bool printerAutoCut; + + /// 当前绑定打印机的 ESC/POS 图片指令模式,默认使用 GS_V0。 + String printerImageMode; + /// 商户部门id String? merchantDeptId; @@ -94,6 +118,18 @@ class SysSetting extends Equatable { // 全局字体大小 SettingGlobalFontSize? globalFontSize; + /// 按钮、标签及小型操作控件的圆角。 + @JsonKey(defaultValue: SettingButtonRadius.standard) + SettingButtonRadius buttonRadius; + + /// 页面分组、信息卡片及 Forui 卡片的圆角。 + @JsonKey(defaultValue: SettingCardRadius.standard) + SettingCardRadius cardRadius; + + /// 按钮和标签的内边距档位,不改变全局字体大小。 + @JsonKey(defaultValue: SettingButtonSize.standard) + SettingButtonSize buttonSize; + // 使用支付方式 List? usePayDisplayType; @@ -103,6 +139,20 @@ class SysSetting extends Equatable { // 租户列表 List? tenantList; + /// 设置为主控后不访问扫码、饭卡和人脸硬件,改由操作员输入硬件结果。 + @JsonKey(defaultValue: false) + bool manualHardwareInput; + + /// Root 最近一次真实命令检查状态;旧配置缺失时按未检查处理。 + @JsonKey(defaultValue: RootAccessStatus.unknown) + RootAccessStatus rootAccessStatus; + + /// Root 最近一次检查时间;旧配置或从未检查时为空。 + DateTime? rootAccessCheckedAt; + + /// Root 检查诊断摘要;可用时说明确认结果,不可用时说明拒绝、超时等原因。 + String? rootAccessDiagnostic; + SysSetting({ required this.sysType, this.androidId, @@ -112,7 +162,17 @@ class SysSetting extends Equatable { this.sqlAddress, this.logAddress, this.hardGroupId, + this.printerPaperWidth = 80, + this.printerAutoCut = true, + this.printerImageMode = 'GS_V0', this.tenantList, + this.manualHardwareInput = false, + this.rootAccessStatus = RootAccessStatus.unknown, + this.rootAccessCheckedAt, + this.rootAccessDiagnostic, + this.buttonRadius = SettingButtonRadius.standard, + this.cardRadius = SettingCardRadius.standard, + this.buttonSize = SettingButtonSize.standard, }); factory SysSetting.fromJson(Map json) => @@ -127,6 +187,14 @@ class SysSetting extends Equatable { serviceId, serviceName, serviceMerchantId, + printerHardId, + printerHardName, + printerModel, + printerUsbVendorId, + printerUsbProductId, + printerPaperWidth, + printerAutoCut, + printerImageMode, merchantDeptId, hardGroupId, mqSocketAddress, @@ -151,9 +219,16 @@ class SysSetting extends Equatable { mqSyncTime?.millisecondsSinceEpoch, hourType, globalFontSize, + buttonRadius, + cardRadius, + buttonSize, usePayDisplayType, defaultPayDisplayType, tenantList, + manualHardwareInput, + rootAccessStatus, + rootAccessCheckedAt?.millisecondsSinceEpoch, + rootAccessDiagnostic, ]; } diff --git a/lib/common/models/database/database_models.g.dart b/lib/common/models/database/database_models.g.dart index e03865f..b2398f9 100644 --- a/lib/common/models/database/database_models.g.dart +++ b/lib/common/models/database/database_models.g.dart @@ -15,12 +15,37 @@ SysSetting _$SysSettingFromJson(Map json) => SysSetting( sqlAddress: json['sqlAddress'] as String?, logAddress: json['logAddress'] as String?, hardGroupId: json['hardGroupId'] as String?, + printerPaperWidth: (json['printerPaperWidth'] as num?)?.toInt() ?? 80, + printerAutoCut: json['printerAutoCut'] as bool? ?? true, + printerImageMode: json['printerImageMode'] as String? ?? 'GS_V0', tenantList: (json['tenantList'] as List?) ?.map((e) => ServiceTenant.fromJson(e as Map)) .toList(), + manualHardwareInput: json['manualHardwareInput'] as bool? ?? false, + rootAccessStatus: $enumDecodeNullable( + _$RootAccessStatusEnumMap, json['rootAccessStatus']) ?? + RootAccessStatus.unknown, + rootAccessCheckedAt: json['rootAccessCheckedAt'] == null + ? null + : DateTime.parse(json['rootAccessCheckedAt'] as String), + rootAccessDiagnostic: json['rootAccessDiagnostic'] as String?, + buttonRadius: $enumDecodeNullable( + _$SettingButtonRadiusEnumMap, json['buttonRadius']) ?? + SettingButtonRadius.standard, + cardRadius: + $enumDecodeNullable(_$SettingCardRadiusEnumMap, json['cardRadius']) ?? + SettingCardRadius.standard, + buttonSize: + $enumDecodeNullable(_$SettingButtonSizeEnumMap, json['buttonSize']) ?? + SettingButtonSize.standard, ) ..serviceName = json['serviceName'] as String? ..serviceMerchantId = json['serviceMerchantId'] as String? + ..printerHardId = json['printerHardId'] as String? + ..printerHardName = json['printerHardName'] as String? + ..printerModel = json['printerModel'] as String? + ..printerUsbVendorId = (json['printerUsbVendorId'] as num?)?.toInt() + ..printerUsbProductId = (json['printerUsbProductId'] as num?)?.toInt() ..merchantDeptId = json['merchantDeptId'] as String? ..mqSocketPort = json['mqSocketPort'] as String? ..mqUsername = json['mqUsername'] as String? @@ -70,6 +95,14 @@ Map _$SysSettingToJson(SysSetting instance) => 'serviceId': instance.serviceId, 'serviceName': instance.serviceName, 'serviceMerchantId': instance.serviceMerchantId, + 'printerHardId': instance.printerHardId, + 'printerHardName': instance.printerHardName, + 'printerModel': instance.printerModel, + 'printerUsbVendorId': instance.printerUsbVendorId, + 'printerUsbProductId': instance.printerUsbProductId, + 'printerPaperWidth': instance.printerPaperWidth, + 'printerAutoCut': instance.printerAutoCut, + 'printerImageMode': instance.printerImageMode, 'merchantDeptId': instance.merchantDeptId, 'hardGroupId': instance.hardGroupId, 'mqSocketAddress': instance.mqSocketAddress, @@ -98,12 +131,19 @@ Map _$SysSettingToJson(SysSetting instance) => 'mqSyncTime': instance.mqSyncTime?.toIso8601String(), 'hourType': _$ProductHourTypeEnumMap[instance.hourType], 'globalFontSize': _$SettingGlobalFontSizeEnumMap[instance.globalFontSize], + 'buttonRadius': _$SettingButtonRadiusEnumMap[instance.buttonRadius]!, + 'cardRadius': _$SettingCardRadiusEnumMap[instance.cardRadius]!, + 'buttonSize': _$SettingButtonSizeEnumMap[instance.buttonSize]!, 'usePayDisplayType': instance.usePayDisplayType ?.map((e) => _$OrderPayDisplayTypeEnumMap[e]!) .toList(), 'defaultPayDisplayType': _$OrderPayDisplayTypeEnumMap[instance.defaultPayDisplayType], 'tenantList': instance.tenantList, + 'manualHardwareInput': instance.manualHardwareInput, + 'rootAccessStatus': _$RootAccessStatusEnumMap[instance.rootAccessStatus]!, + 'rootAccessCheckedAt': instance.rootAccessCheckedAt?.toIso8601String(), + 'rootAccessDiagnostic': instance.rootAccessDiagnostic, }; const _$SysTypeEnumMap = { @@ -111,6 +151,30 @@ const _$SysTypeEnumMap = { SysType.CANTEEN: 'CANTEEN', }; +const _$RootAccessStatusEnumMap = { + RootAccessStatus.unknown: 'unknown', + RootAccessStatus.available: 'available', + RootAccessStatus.unavailable: 'unavailable', +}; + +const _$SettingButtonRadiusEnumMap = { + SettingButtonRadius.compact: 'compact', + SettingButtonRadius.standard: 'standard', + SettingButtonRadius.large: 'large', +}; + +const _$SettingCardRadiusEnumMap = { + SettingCardRadius.compact: 'compact', + SettingCardRadius.standard: 'standard', + SettingCardRadius.large: 'large', +}; + +const _$SettingButtonSizeEnumMap = { + SettingButtonSize.compact: 'compact', + SettingButtonSize.standard: 'standard', + SettingButtonSize.large: 'large', +}; + const _$ProductHourTypeEnumMap = { ProductHourType.TOTAL: 'TOTAL', ProductHourType.MORNING: 'MORNING', diff --git a/lib/common/models/index.dart b/lib/common/models/index.dart index adda144..b519847 100644 --- a/lib/common/models/index.dart +++ b/lib/common/models/index.dart @@ -14,6 +14,8 @@ export './page/order_page_enum_models.dart'; export './page/order_page_models.dart'; export './page/panel_page_models.dart'; export './page/setting_page_enum_models.dart'; +export './page/reservation_menu_page_models.dart'; +export './page/product_meal_period_models.dart'; export './page/wallet_page_enum_models.dart'; export './mq/mq_enum_models.dart'; @@ -21,6 +23,13 @@ export './mq/mq_models.dart'; export './manage/usb_facility_enum_manage.dart'; export './manage/usb_facility_manage.dart'; +export './manage/terminal_device_status_models.dart'; +export './manage/order_evidence_models.dart'; +export './manage/scheduled_task_models.dart'; +export './manage/face_sync_models.dart'; +export './manage/root_access_models.dart'; +export './manage/printer_device_models.dart'; +export './manage/hardware_input_history_models.dart'; export './encryption/encryption_tool.dart'; @@ -28,6 +37,8 @@ export './database/database_enum_models.dart'; export './database/database_models.dart'; export './plugs/face_discern_models.dart'; +export './plugs/face_recognition_session_models.dart'; +export './plugs/face_startup_preparation_models.dart'; export './plugs/serial_card_enum_models.dart'; export './plugs/serial_card_models.dart'; export './plugs/sub_screen_enum_models.dart'; diff --git a/lib/common/models/manage/face_sync_models.dart b/lib/common/models/manage/face_sync_models.dart new file mode 100644 index 0000000..28b52a0 --- /dev/null +++ b/lib/common/models/manage/face_sync_models.dart @@ -0,0 +1,93 @@ +/// 人脸同步的业务范围,所有入口最终映射为部门快照或用户增量。 +enum FaceSyncMode { + /// 本部门及子部门的完整权威快照,用于每日对账和人工同步。 + departmentSnapshot, + + /// 指定用户的最新状态回查,用于 MQ 低延迟增量同步。 + userDelta, + + /// 非生产调试使用的全租户快照,生产界面不会暴露该模式。 + debugAllSnapshot, +} + +/// 一次人脸差异同步的结构化汇总,可直接映射到现有任务日志统计列。 +class FaceSyncSummary { + /// 创建同步汇总;失败样本只保存脱敏标识,不保存姓名、图片地址或特征。 + const FaceSyncSummary({ + required this.mode, + required this.scanned, + required this.added, + required this.updated, + required this.deleted, + required this.skipped, + required this.failed, + required this.maskedFailedSampleIds, + }); + + /// 本次同步模式。 + final FaceSyncMode mode; + + /// 从服务端完整读取并校验的记录数。 + final int scanned; + + /// 新增并成功写入本地数据库及虹软内存库的样本数。 + final int added; + + /// 元数据或图片版本成功更新的样本数。 + final int updated; + + /// 成功从虹软、本地数据库和正式文件范围移除的孤儿样本数。 + final int deleted; + + /// 服务端与本地版本完全一致、未发生下载或虹软写入的样本数。 + final int skipped; + + /// 本次处理失败且保留待下次重试的样本数。 + final int failed; + + /// 失败样本的脱敏编号,最多由同步服务保留有限数量用于现场定位。 + final List maskedFailedSampleIds; + + /// 本次成功新增、更新和删除的变更总数。 + int get changed => added + updated + deleted; + + /// 是否存在需要下次重试的失败样本。 + bool get hasFailures => failed > 0; + + /// 面向任务日志和设置页的简短结果描述。 + String get message { + if (!hasFailures) { + return '扫描 $scanned,新增 $added,更新 $updated,删除 $deleted,跳过 $skipped'; + } + final failedSamples = maskedFailedSampleIds.isEmpty + ? '' + : ',失败样本 ${maskedFailedSampleIds.join(',')}'; + return '扫描 $scanned,新增 $added,更新 $updated,删除 $deleted,失败 $failed$failedSamples'; + } +} + +/// V3 快照不完整、游标倒退或样本标识冲突时抛出的整次同步异常。 +class FaceSyncSnapshotException implements Exception { + /// 使用不包含图片地址和人员信息的安全摘要创建快照异常。 + const FaceSyncSnapshotException(this.message); + + /// 可写入终端任务日志的安全错误摘要。 + final String message; + + /// 返回安全错误摘要。 + @override + String toString() => message; +} + +/// 部分样本失败时交给统一调度器记录统计的异常。 +class FaceSyncPartialFailureException implements Exception { + /// 创建包含脱敏失败样本和完整计数的部分失败异常。 + const FaceSyncPartialFailureException(this.summary); + + /// 已完成的同步统计,失败项均保留旧数据等待下次重试。 + final FaceSyncSummary summary; + + /// 返回面向设置页的汇总描述。 + @override + String toString() => summary.message; +} diff --git a/lib/common/models/manage/hardware_input_history_models.dart b/lib/common/models/manage/hardware_input_history_models.dart new file mode 100644 index 0000000..cccf30a --- /dev/null +++ b/lib/common/models/manage/hardware_input_history_models.dart @@ -0,0 +1,115 @@ +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'hardware_input_history_models.g.dart'; + +/// 虚拟机人工硬件输入的本地历史集合,按“路由 + 功能键”隔离不同场景。 +@immutable +@JsonSerializable(explicitToJson: true) +class HardwareInputHistoryStore { + const HardwareInputHistoryStore({this.scenes = const []}); + + /// 尚未保存任何人工输入时使用的空历史集合。 + static const HardwareInputHistoryStore empty = HardwareInputHistoryStore(); + + /// 当前终端已保存的人工输入场景列表。 + final List scenes; + + factory HardwareInputHistoryStore.fromJson(Map json) => + _$HardwareInputHistoryStoreFromJson(json); + + Map toJson() => _$HardwareInputHistoryStoreToJson(this); + + /// 获取指定场景的最近输入;场景不存在时返回空列表。 + List entriesFor(String sceneKey) { + for (final scene in scenes) { + if (scene.sceneKey == sceneKey) { + return List.unmodifiable( + scene.entries.take(HardwareInputHistoryEntry.maxCount), + ); + } + } + return const []; + } + + /// 保存一条场景输入,相同值更新备注并置顶,不同场景互不影响。 + HardwareInputHistoryStore save({ + required String sceneKey, + required String value, + required String remark, + required DateTime updatedAt, + }) { + final normalizedSceneKey = sceneKey.trim(); + final normalizedValue = value.trim(); + if (normalizedSceneKey.isEmpty || normalizedValue.isEmpty) return this; + + final nextScene = HardwareInputHistoryScene( + sceneKey: normalizedSceneKey, + entries: [ + HardwareInputHistoryEntry( + value: normalizedValue, + remark: remark.trim(), + updatedAt: updatedAt, + ), + ...entriesFor(normalizedSceneKey).where( + (entry) => entry.value != normalizedValue, + ), + ].take(HardwareInputHistoryEntry.maxCount).toList(growable: false), + ); + return HardwareInputHistoryStore( + scenes: [ + nextScene, + ...scenes.where((scene) => scene.sceneKey != normalizedSceneKey), + ], + ); + } +} + +/// 单个“路由 + 功能键”对应的人工输入历史。 +@immutable +@JsonSerializable(explicitToJson: true) +class HardwareInputHistoryScene { + const HardwareInputHistoryScene({ + required this.sceneKey, + this.entries = const [], + }); + + /// 由当前路由和调用方功能键组成的稳定场景标识。 + final String sceneKey; + + /// 当前场景按最近使用时间倒序排列的输入记录。 + final List entries; + + factory HardwareInputHistoryScene.fromJson(Map json) => + _$HardwareInputHistorySceneFromJson(json); + + Map toJson() => _$HardwareInputHistorySceneToJson(this); +} + +/// 虚拟机人工硬件输入的一条本地历史记录。 +@immutable +@JsonSerializable() +class HardwareInputHistoryEntry { + const HardwareInputHistoryEntry({ + required this.value, + required this.remark, + required this.updatedAt, + }); + + /// 每个“路由 + 功能键”场景最多保留的记录数。 + static const int maxCount = 5; + + /// 提交给业务流程的原始输入值。 + final String value; + + /// 用户为编号、卡号等调试数据补充的可选说明。 + final String remark; + + /// 记录最近一次提交的时间,用于保持历史记录的最近使用顺序。 + final DateTime updatedAt; + + factory HardwareInputHistoryEntry.fromJson(Map json) => + _$HardwareInputHistoryEntryFromJson(json); + + Map toJson() => _$HardwareInputHistoryEntryToJson(this); +} diff --git a/lib/common/models/manage/hardware_input_history_models.g.dart b/lib/common/models/manage/hardware_input_history_models.g.dart new file mode 100644 index 0000000..7daee15 --- /dev/null +++ b/lib/common/models/manage/hardware_input_history_models.g.dart @@ -0,0 +1,57 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'hardware_input_history_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +HardwareInputHistoryStore _$HardwareInputHistoryStoreFromJson( + Map json) => + HardwareInputHistoryStore( + scenes: (json['scenes'] as List?) + ?.map((e) => + HardwareInputHistoryScene.fromJson(e as Map)) + .toList() ?? + const [], + ); + +Map _$HardwareInputHistoryStoreToJson( + HardwareInputHistoryStore instance) => + { + 'scenes': instance.scenes.map((e) => e.toJson()).toList(), + }; + +HardwareInputHistoryScene _$HardwareInputHistorySceneFromJson( + Map json) => + HardwareInputHistoryScene( + sceneKey: json['sceneKey'] as String, + entries: (json['entries'] as List?) + ?.map((e) => + HardwareInputHistoryEntry.fromJson(e as Map)) + .toList() ?? + const [], + ); + +Map _$HardwareInputHistorySceneToJson( + HardwareInputHistoryScene instance) => + { + 'sceneKey': instance.sceneKey, + 'entries': instance.entries.map((e) => e.toJson()).toList(), + }; + +HardwareInputHistoryEntry _$HardwareInputHistoryEntryFromJson( + Map json) => + HardwareInputHistoryEntry( + value: json['value'] as String, + remark: json['remark'] as String, + updatedAt: DateTime.parse(json['updatedAt'] as String), + ); + +Map _$HardwareInputHistoryEntryToJson( + HardwareInputHistoryEntry instance) => + { + 'value': instance.value, + 'remark': instance.remark, + 'updatedAt': instance.updatedAt.toIso8601String(), + }; diff --git a/lib/common/models/manage/order_evidence_models.dart b/lib/common/models/manage/order_evidence_models.dart new file mode 100644 index 0000000..ffd798d --- /dev/null +++ b/lib/common/models/manage/order_evidence_models.dart @@ -0,0 +1,100 @@ +/// 订单识别证据业务类型,编码与服务端 `hard_order_evidence_image.evidence_type` 保持一致。 +enum OrderEvidenceType { + /// 人脸比对形成的参考图与现场图证据。 + faceRecognition('FACE_RECOGNITION'), + + /// 预留的菜谱识别现场图证据,本期不执行识别与上传创建逻辑。 + menuRecognition('MENU_RECOGNITION'); + + /// 创建一个跨端协议使用的稳定证据类型。 + const OrderEvidenceType(this.code); + + /// 服务端接口和数据库保存的稳定编码,不能随界面文案变化。 + final String code; +} + +/// 订单识别证据图片角色,编码与服务端 `hard_order_evidence_image.evidence_role` 保持一致。 +enum OrderEvidenceRole { + /// 识别时命中的后台受信人脸图片,由服务端复制,终端不下载后再上传。 + faceReference('FACE_REFERENCE'), + + /// 人脸真正比对成功时从对应 NV21 帧裁剪的现场图片。 + faceLive('FACE_LIVE'), + + /// 预留的菜谱识别现场图片角色,本期不创建该角色任务。 + menuLive('MENU_LIVE'); + + /// 创建一个跨端协议使用的稳定图片角色。 + const OrderEvidenceRole(this.code); + + /// 服务端接口和数据库保存的稳定编码,不能随界面文案变化。 + final String code; +} + +/// 终端订单证据补传任务状态,编码持久化到 `order_evidence_upload_task.status`。 +enum OrderEvidenceTaskStatus { + /// 已保存识别快照,但尚未确认服务端存在已支付订单。 + waitingOrder('WAITING_ORDER'), + + /// 已通过终端订单号对账到服务端已支付订单,可以提交或上传证据。 + ready('READY'), + + /// 当前进程正在提交证据;应用异常退出后会在下一轮恢复为可重试状态。 + uploading('UPLOADING'), + + /// 服务端已确认 AVAILABLE,现场临时图片已经删除。 + success('SUCCESS'), + + /// 本次网络、文件中心或临时依赖失败,等待 `nextRetryAt` 后重试。 + retryWait('RETRY_WAIT'), + + /// 哈希冲突、归属校验失败等确定不可恢复的失败,元数据长期保留。 + permanentFailed('PERMANENT_FAILED'), + + /// 图片或未绑定订单超过保留期,图片已删除且元数据长期保留。 + expired('EXPIRED'), + + /// 用户在下单前取消或切换支付方式,现场图片已删除且不再自动处理。 + cancelled('CANCELLED'); + + /// 创建一个持久化使用的稳定任务状态。 + const OrderEvidenceTaskStatus(this.code); + + /// SQLite 持久化和诊断统计使用的稳定编码。 + final String code; + + /// 按 SQLite 或接口编码解析状态;未知历史编码返回空,避免误执行任务。 + static OrderEvidenceTaskStatus? fromCode(String code) { + for (final value in values) { + if (value.code == code) return value; + } + return null; + } +} + +/// 单轮订单证据补传统计,供统一定时任务写入结构化执行日志。 +class OrderEvidenceUploadSummary { + /// 创建一份不可变的补传结果统计。 + const OrderEvidenceUploadSummary({ + this.scanned = 0, + this.skipped = 0, + this.succeeded = 0, + this.failed = 0, + }); + + /// 本轮从 SQLite 取得并检查的任务数量。 + final int scanned; + + /// 因未到重试时间、订单未落库或已处于终态而跳过的任务数量。 + final int skipped; + + /// 本轮被服务端确认成功或永久缺失已上报成功的任务数量。 + final int succeeded; + + /// 本轮进入重试等待或确定失败的任务数量。 + final int failed; + + /// 设置页和最近执行记录使用的简短中文摘要。 + String get message => + '检查 $scanned 条,成功 $succeeded 条,跳过 $skipped 条,失败 $failed 条'; +} diff --git a/lib/common/models/manage/printer_device_models.dart b/lib/common/models/manage/printer_device_models.dart new file mode 100644 index 0000000..9d5dda5 --- /dev/null +++ b/lib/common/models/manage/printer_device_models.dart @@ -0,0 +1,77 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'printer_device_models.g.dart'; + +/// Android USB Host 实际枚举到的一台标准 Printer Class 设备。 +class PrinterUsbDeviceInfo { + /// 创建一次只读的 USB 打印机发现快照。 + const PrinterUsbDeviceInfo({ + required this.manufacturerName, + required this.productName, + required this.deviceId, + required this.vendorId, + required this.productId, + }); + + /// USB 描述符中的厂商名称;读取不到时为空。 + final String? manufacturerName; + + /// USB 描述符中的产品名称;读取不到时为空。 + final String? productName; + + /// Android 在本次连接期间分配的 USB 设备编号;重新插拔后可能变化。 + final int? deviceId; + + /// USB Vendor ID 十进制值,范围为 0 到 65535。 + final int vendorId; + + /// USB Product ID 十进制值,范围为 0 到 65535。 + final int productId; + + /// 页面列表选择使用的进程内稳定键,不作为服务端或数据库主键。 + String get selectionKey => '$vendorId:$productId:${deviceId ?? -1}'; + + /// 优先组合厂商和产品描述,缺少描述符时回退为通用打印机名称。 + String get displayName { + final parts = [ + if (manufacturerName?.trim().isNotEmpty == true) manufacturerName!.trim(), + if (productName?.trim().isNotEmpty == true) productName!.trim(), + ]; + return parts.isEmpty ? 'USB Printer' : parts.toSet().join(' / '); + } + + /// 将 USB 编号格式化为四位大写十六进制,便于与设备文档交叉核对。 + String get hexadecimalIdentity => + '0x${vendorId.toRadixString(16).padLeft(4, '0').toUpperCase()} / ' + '0x${productId.toRadixString(16).padLeft(4, '0').toUpperCase()}'; + + /// 生成只包含服务端允许字段的打印机信息上报请求。 + PrinterInfoReportRequest toReportRequest() => PrinterInfoReportRequest( + printerModel: displayName, + usbVendorId: vendorId, + usbProductId: productId, + ); +} + +/// 终端向服务端上报实际连接打印机型号与 USB 标识的请求。 +@JsonSerializable(createFactory: false) +class PrinterInfoReportRequest { + /// 创建一份不包含设备路径、序列号等敏感信息的上报请求。 + const PrinterInfoReportRequest({ + required this.printerModel, + required this.usbVendorId, + required this.usbProductId, + }); + + /// 由 USB 厂商和产品描述组合的打印机型号,服务端最长保存 128 个字符。 + final String printerModel; + + /// USB Vendor ID 十进制值。 + final int usbVendorId; + + /// USB Product ID 十进制值。 + final int usbProductId; + + /// 转换为终端打印机信息上报接口使用的 JSON 对象。 + Map toJson() => _$PrinterInfoReportRequestToJson(this); +} diff --git a/lib/common/models/manage/printer_device_models.g.dart b/lib/common/models/manage/printer_device_models.g.dart new file mode 100644 index 0000000..da21c02 --- /dev/null +++ b/lib/common/models/manage/printer_device_models.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'printer_device_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Map _$PrinterInfoReportRequestToJson( + PrinterInfoReportRequest instance) => + { + 'printerModel': instance.printerModel, + 'usbVendorId': instance.usbVendorId, + 'usbProductId': instance.usbProductId, + }; diff --git a/lib/common/models/manage/root_access_models.dart b/lib/common/models/manage/root_access_models.dart new file mode 100644 index 0000000..def016d --- /dev/null +++ b/lib/common/models/manage/root_access_models.dart @@ -0,0 +1,49 @@ +/// Android Root 权限的持久化检查状态,枚举名写入本机 sysJson,不能随界面文案调整。 +enum RootAccessStatus { + /// 尚未完成过有效检测,包含旧版本配置升级后的默认状态。 + unknown, + + /// `su -c id` 在超时内成功返回且输出确认当前 UID 为 0。 + available, + + /// Root 被拒绝、命令不存在、执行超时或返回结果未确认 UID 为 0。 + unavailable, +} + +/// 单次 Root 检查结果,原生层负责命令执行,Flutter 负责持久化和界面反馈。 +class RootAccessCheckResult { + /// 创建一份带检查时间与诊断摘要的 Root 结果。 + const RootAccessCheckResult({ + required this.status, + required this.checkedAt, + required this.diagnostic, + }); + + /// 本次检查得出的可用状态。 + final RootAccessStatus status; + + /// Flutter 收到原生检查结果的本地时间。 + final DateTime checkedAt; + + /// 可供运维排查的简短摘要,不保存完整命令输出或敏感环境信息。 + final String diagnostic; + + /// Root 是否已经通过真实命令验证。 + bool get available => status == RootAccessStatus.available; + + /// 从原生 MethodChannel 结构化结果创建检查记录,未知编码按不可用处理。 + factory RootAccessCheckResult.fromMap(Map? data) { + final available = data?['available'] == true; + final diagnostic = data?['message']?.toString().trim(); + return RootAccessCheckResult( + status: + available ? RootAccessStatus.available : RootAccessStatus.unavailable, + checkedAt: DateTime.now(), + diagnostic: diagnostic?.isNotEmpty == true + ? diagnostic! + : available + ? '已确认 uid=0' + : '未获得 Root 权限', + ); + } +} diff --git a/lib/common/models/manage/scheduled_task_models.dart b/lib/common/models/manage/scheduled_task_models.dart new file mode 100644 index 0000000..8d81fc6 --- /dev/null +++ b/lib/common/models/manage/scheduled_task_models.dart @@ -0,0 +1,291 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'scheduled_task_models.g.dart'; + +/// 应用内统一调度的任务标识,枚举名作为 scheduled_task.taskCode,不能随 UI 文案变化。 +enum ScheduledTaskType { + /// MQTT 长连接断开后的重连检查。 + mqReconnect, + + /// 待打印任务与打印结果同步检查。 + printCheck, + + /// 取餐广播本地队列检查。 + broadcastCheck, + + /// 打印机、电子秤、读卡器等终端硬件状态检查。 + deviceStatus, + + /// 检查并通过 Root 静默安装最高的已发布 Android APK 版本。 + autoUpdate, + + /// 服务端设备配置同步。 + configSync, + + /// 本部门及子部门人脸权威快照同步;MQ 用户增量也复用同一任务队列。 + departmentFaceSync, + + /// 商品分类数据同步。 + classifySync, + + /// 商品主数据同步。 + productSync, + + /// 本地离线订单补传。 + offlineOrderSync, + + /// 已支付订单的人脸等识别证据对账、上传和永久缺失上报。 + orderEvidenceUpload, + + /// 本机脱敏日志文件上传。 + terminalLogUpload, + + /// 已成功上传的过期日志清理。 + expiredLogCleanup, +} + +/// 任务最近一次执行状态,枚举名直接持久化到任务表和任务日志表。 +enum ScheduledTaskRunStatus { + /// 尚未产生过执行记录。 + notRun, + + /// 任务已经开始但尚未结束。 + running, + + /// 任务已完成且业务结果成功。 + success, + + /// 任务执行失败并记录了错误信息。 + failed, + + /// 因网络不可用或数据未变化而无需执行后续动作。 + skipped, +} + +/// 任务触发来源,枚举名写入 scheduled_task_log.triggerType。 +enum ScheduledTaskTriggerType { + /// Cron 到点自动触发。 + cron, + + /// 设置页“立即执行”人工触发。 + manual, + + /// 后台通过 MQTT 指令触发。 + mqtt, + + /// 应用启动或其他内部流程触发。 + system, +} + +/// 任务依赖的主要资源类型,用于执行日志筛选和故障归类。 +enum ScheduledTaskSourceType { + /// MQTT 消息服务。 + mqtt, + + /// HTTP 业务接口。 + http, + + /// 打印、串口或其他终端插件。 + plugin, + + /// 本地日志等文件系统资源。 + file, + + /// 不依赖远程服务的本地队列或本地逻辑。 + local, +} + +/// 本机文件上传记录状态,枚举名写入 file_upload_record.status。 +enum FileUploadStatus { + /// 已创建上传尝试,文件尚未确认成功。 + uploading, + + /// 服务端确认无需上传或已经上传成功。 + success, + + /// 本次预检、脱敏或上传处理失败。 + failed, +} + +/// Cron 常用规则在选择弹窗中的分组。 +enum ScheduledCronPresetGroup { + /// 按秒、分钟或小时周期执行。 + interval, + + /// 每天固定时间执行。 + daily, + + /// 每周固定星期和时间执行。 + weekly, + + /// 每月固定日期和时间执行。 + monthly, +} + +/// Cron 弹窗使用的数据模型,表达式仍以调度器实际支持的六段格式保存。 +class ScheduledCronPreset { + /// 创建一条可直接应用到任务配置的 Cron 预设。 + const ScheduledCronPreset({ + required this.group, + required this.label, + required this.expression, + }); + + /// 预设在选择弹窗中的分类。 + final ScheduledCronPresetGroup group; + + /// 面向终端操作人员展示的规则名称。 + final String label; + + /// cron 0.6.2 支持的六段表达式,顺序为秒、分、时、日、月、周。 + final String expression; +} + +/// 定时任务可编辑配置,旧 JSON 只在 SQLite v8 首次迁移时读取。 +@JsonSerializable() +class ScheduledTaskConfig { + /// 稳定任务类型,对应 scheduled_task.taskCode。 + final ScheduledTaskType type; + + /// 是否注册自动 Cron 调度;关闭后仍允许手动执行。 + final bool enabled; + + /// 六段 Cron 表达式。 + final String cron; + + /// 自动触发后是否必须等待连续 60 秒无人操作的完整空闲状态。 + final bool idleOnly; + + /// 创建一份任务调度配置。 + const ScheduledTaskConfig({ + required this.type, + required this.enabled, + required this.cron, + required this.idleOnly, + }); + + /// 从旧版 sysJson 恢复迁移用任务配置。 + factory ScheduledTaskConfig.fromJson(Map json) => + _$ScheduledTaskConfigFromJson(json); + + /// 仅供旧版配置兼容和代码生成使用,不作为 v8 之后的持久化入口。 + Map toJson() => _$ScheduledTaskConfigToJson(this); + + /// 保留任务类型并生成包含指定变更的新配置。 + ScheduledTaskConfig copyWith({ + bool? enabled, + String? cron, + bool? idleOnly, + }) { + return ScheduledTaskConfig( + type: type, + enabled: enabled ?? this.enabled, + cron: cron ?? this.cron, + idleOnly: idleOnly ?? this.idleOnly, + ); + } +} + +/// 设置页展示的任务最近执行快照。 +@JsonSerializable() +class ScheduledTaskRecord { + /// 最近一次开始执行的本地时间;从未执行时为空。 + final DateTime? lastRunAt; + + /// 最近一次成功完成的本地时间;尚无成功记录时为空。 + final DateTime? lastSuccessAt; + + /// 最近结果是否可视为成功,跳过状态也按可继续运行处理。 + final bool success; + + /// 最近结果的简短中文摘要。 + final String message; + + /// 创建任务最近执行快照。 + const ScheduledTaskRecord({ + this.lastRunAt, + this.lastSuccessAt, + this.success = true, + this.message = '尚未执行', + }); + + /// 从旧版 sysJson 恢复迁移用执行快照。 + factory ScheduledTaskRecord.fromJson(Map json) => + _$ScheduledTaskRecordFromJson(json); + + /// 仅供旧版执行快照兼容和代码生成使用。 + Map toJson() => _$ScheduledTaskRecordToJson(this); +} + +/// 日志文件最近一次成功上传状态,用于本地未变化文件快速跳过。 +@JsonSerializable() +class TerminalLogUploadState { + /// 日志根目录下使用正斜杠的相对路径。 + final String relativePath; + + /// 生成脱敏副本时读取的原日志快照大小,单位为字节。 + final int sourceSize; + + /// 生成脱敏副本时原日志文件的最后修改时间。 + final DateTime sourceModifiedTime; + + /// 脱敏后文件内容的 MD5 哈希。 + final String sanitizedHash; + + /// 服务端确认成功或无需重复上传的本地时间。 + final DateTime uploadedAt; + + /// 创建一条成功上传状态。 + const TerminalLogUploadState({ + required this.relativePath, + required this.sourceSize, + required this.sourceModifiedTime, + required this.sanitizedHash, + required this.uploadedAt, + }); + + /// 从旧版 sysJson 恢复待迁移的文件状态。 + factory TerminalLogUploadState.fromJson(Map json) => + _$TerminalLogUploadStateFromJson(json); + + /// 仅供旧版文件状态兼容和代码生成使用。 + Map toJson() => _$TerminalLogUploadStateToJson(this); +} + +/// 单次终端日志上传任务的汇总统计。 +@JsonSerializable() +class TerminalLogUploadSummary { + /// 本次进入处理范围的日志文件数量。 + final int scanned; + + /// 因本地快照未变化或服务端已有相同内容而跳过的数量。 + final int skipped; + + /// 本次实际上传成功的文件数量。 + final int succeeded; + + /// 本次脱敏、预检或上传失败的文件数量。 + final int failed; + + /// 本次任务内最近一次成功确认时间;没有成功文件时为空。 + final DateTime? lastSuccessAt; + + /// 创建一次日志上传汇总,未传入的计数按零处理。 + const TerminalLogUploadSummary({ + this.scanned = 0, + this.skipped = 0, + this.succeeded = 0, + this.failed = 0, + this.lastSuccessAt, + }); + + /// 从旧版 sysJson 恢复迁移用上传汇总。 + factory TerminalLogUploadSummary.fromJson(Map json) => + _$TerminalLogUploadSummaryFromJson(json); + + /// 仅供旧版上传汇总兼容和代码生成使用。 + Map toJson() => _$TerminalLogUploadSummaryToJson(this); + + /// 生成设置页和任务日志共用的中文统计摘要。 + String get label => '扫描 $scanned,跳过 $skipped,成功 $succeeded,失败 $failed'; +} diff --git a/lib/common/models/manage/scheduled_task_models.g.dart b/lib/common/models/manage/scheduled_task_models.g.dart new file mode 100644 index 0000000..b09cce3 --- /dev/null +++ b/lib/common/models/manage/scheduled_task_models.g.dart @@ -0,0 +1,103 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'scheduled_task_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ScheduledTaskConfig _$ScheduledTaskConfigFromJson(Map json) => + ScheduledTaskConfig( + type: $enumDecode(_$ScheduledTaskTypeEnumMap, json['type']), + enabled: json['enabled'] as bool, + cron: json['cron'] as String, + idleOnly: json['idleOnly'] as bool, + ); + +Map _$ScheduledTaskConfigToJson( + ScheduledTaskConfig instance) => + { + 'type': _$ScheduledTaskTypeEnumMap[instance.type]!, + 'enabled': instance.enabled, + 'cron': instance.cron, + 'idleOnly': instance.idleOnly, + }; + +const _$ScheduledTaskTypeEnumMap = { + ScheduledTaskType.mqReconnect: 'mqReconnect', + ScheduledTaskType.printCheck: 'printCheck', + ScheduledTaskType.broadcastCheck: 'broadcastCheck', + ScheduledTaskType.deviceStatus: 'deviceStatus', + ScheduledTaskType.autoUpdate: 'autoUpdate', + ScheduledTaskType.configSync: 'configSync', + ScheduledTaskType.departmentFaceSync: 'departmentFaceSync', + ScheduledTaskType.classifySync: 'classifySync', + ScheduledTaskType.productSync: 'productSync', + ScheduledTaskType.offlineOrderSync: 'offlineOrderSync', + ScheduledTaskType.orderEvidenceUpload: 'orderEvidenceUpload', + ScheduledTaskType.terminalLogUpload: 'terminalLogUpload', + ScheduledTaskType.expiredLogCleanup: 'expiredLogCleanup', +}; + +ScheduledTaskRecord _$ScheduledTaskRecordFromJson(Map json) => + ScheduledTaskRecord( + lastRunAt: json['lastRunAt'] == null + ? null + : DateTime.parse(json['lastRunAt'] as String), + lastSuccessAt: json['lastSuccessAt'] == null + ? null + : DateTime.parse(json['lastSuccessAt'] as String), + success: json['success'] as bool? ?? true, + message: json['message'] as String? ?? '尚未执行', + ); + +Map _$ScheduledTaskRecordToJson( + ScheduledTaskRecord instance) => + { + 'lastRunAt': instance.lastRunAt?.toIso8601String(), + 'lastSuccessAt': instance.lastSuccessAt?.toIso8601String(), + 'success': instance.success, + 'message': instance.message, + }; + +TerminalLogUploadState _$TerminalLogUploadStateFromJson( + Map json) => + TerminalLogUploadState( + relativePath: json['relativePath'] as String, + sourceSize: (json['sourceSize'] as num).toInt(), + sourceModifiedTime: DateTime.parse(json['sourceModifiedTime'] as String), + sanitizedHash: json['sanitizedHash'] as String, + uploadedAt: DateTime.parse(json['uploadedAt'] as String), + ); + +Map _$TerminalLogUploadStateToJson( + TerminalLogUploadState instance) => + { + 'relativePath': instance.relativePath, + 'sourceSize': instance.sourceSize, + 'sourceModifiedTime': instance.sourceModifiedTime.toIso8601String(), + 'sanitizedHash': instance.sanitizedHash, + 'uploadedAt': instance.uploadedAt.toIso8601String(), + }; + +TerminalLogUploadSummary _$TerminalLogUploadSummaryFromJson( + Map json) => + TerminalLogUploadSummary( + scanned: (json['scanned'] as num?)?.toInt() ?? 0, + skipped: (json['skipped'] as num?)?.toInt() ?? 0, + succeeded: (json['succeeded'] as num?)?.toInt() ?? 0, + failed: (json['failed'] as num?)?.toInt() ?? 0, + lastSuccessAt: json['lastSuccessAt'] == null + ? null + : DateTime.parse(json['lastSuccessAt'] as String), + ); + +Map _$TerminalLogUploadSummaryToJson( + TerminalLogUploadSummary instance) => + { + 'scanned': instance.scanned, + 'skipped': instance.skipped, + 'succeeded': instance.succeeded, + 'failed': instance.failed, + 'lastSuccessAt': instance.lastSuccessAt?.toIso8601String(), + }; diff --git a/lib/common/models/manage/terminal_device_status_models.dart b/lib/common/models/manage/terminal_device_status_models.dart new file mode 100644 index 0000000..d875cc3 --- /dev/null +++ b/lib/common/models/manage/terminal_device_status_models.dart @@ -0,0 +1,183 @@ +import 'dart:convert'; + +/// 终端状态检查支持的设备类型;设备编码仅用于本机 `sysJson` 显隐缓存,不属于服务端接口或数据库字段。 +enum TerminalDeviceType { + /// USB 小票打印机。 + printer('printer', '打印机'), + + /// 通过串口持续上报重量的电子秤。 + steelyard('steelyard', '电子秤'), + + /// 通过串口执行饭卡读写的读卡器。 + cardReader('cardReader', '读卡器'), + + /// Android Presentation 使用的顾客副屏。 + subScreen('subScreen', '副屏'), + + /// 虹软人脸识别使用的 Android 摄像头。 + camera('camera', '摄像头'); + + /// 创建带稳定缓存编码和中文名称的设备类型;编码写入后不得随枚举命名调整。 + const TerminalDeviceType(this.code, this.label); + + /// 本机顶部显隐缓存使用的稳定编码;不上传服务端,也不映射数据库字段。 + final String code; + + /// 设置页检查进度和日志使用的中文设备名称。 + final String label; +} + +/// 一轮终端设备检查的当前进度。 +class TerminalDeviceCheckProgress { + /// 创建检查进度;未运行时保持默认空状态。 + const TerminalDeviceCheckProgress({ + this.running = false, + this.currentDevice, + this.remainingSeconds = 0, + this.completedCount = 0, + this.totalCount = 5, + }); + + /// 是否正在执行一轮设备检查。 + final bool running; + + /// 当前正在探测的设备;未运行时为空。 + final TerminalDeviceType? currentDevice; + + /// 当前设备探测超时前剩余的整秒数。 + final int remainingSeconds; + + /// 本轮已经完成探测的设备数量。 + final int completedCount; + + /// 本轮需要探测的设备总数,当前包含五类终端设备。 + final int totalCount; + + /// 生成供设置页展示的当前检查提示;未运行时返回空字符串。 + String get message { + if (!running || currentDevice == null) return ''; + return '正在检查${currentDevice!.label},剩余 ${remainingSeconds}s,点击取消'; + } +} + +/// 终端硬件状态快照只保存可用性与最近检查时间,不承载设备连接实例。 +class TerminalDeviceStatusSnapshot { + /// 创建一次完整设备探测结果。 + const TerminalDeviceStatusSnapshot({ + required this.printerAvailable, + required this.steelyardAvailable, + required this.cardReaderAvailable, + required this.subScreenAvailable, + required this.cameraAvailable, + this.updatedAt, + }); + + /// 创建尚未完成任何设备探测的空快照。 + const TerminalDeviceStatusSnapshot.empty() + : printerAvailable = false, + steelyardAvailable = false, + cardReaderAvailable = false, + subScreenAvailable = false, + cameraAvailable = false, + updatedAt = null; + + /// 最近一次探测是否发现可用的小票打印机。 + final bool printerAvailable; + + /// 最近一次探测是否收到电子秤的有效重量帧。 + final bool steelyardAvailable; + + /// 最近一次探测是否发现可用的读卡串口。 + final bool cardReaderAvailable; + + /// 最近一次探测是否发现副屏且具备 Overlay 权限。 + final bool subScreenAvailable; + + /// 最近一次探测是否枚举到可供人脸识别使用的 Android 摄像头。 + final bool cameraAvailable; + + /// 最近一次完整或单项设备状态更新的本地时间;未检查时为空。 + final DateTime? updatedAt; + + /// 从兼容旧缓存的 JSON 对象恢复设备状态,缺少的新字段按不可用处理。 + factory TerminalDeviceStatusSnapshot.fromJson(Map json) { + return TerminalDeviceStatusSnapshot( + printerAvailable: json['printerAvailable'] == true, + steelyardAvailable: json['steelyardAvailable'] == true, + cardReaderAvailable: json['cardReaderAvailable'] == true, + subScreenAvailable: json['subScreenAvailable'] == true, + cameraAvailable: json['cameraAvailable'] == true, + updatedAt: DateTime.tryParse(json['updatedAt']?.toString() ?? ''), + ); + } + + /// 尝试解析设备缓存文本;空文本、格式错误或非对象 JSON 返回空。 + static TerminalDeviceStatusSnapshot? tryParse(String? source) { + if (source == null || source.trim().isEmpty) return null; + try { + final json = jsonDecode(source); + if (json is! Map) return null; + return TerminalDeviceStatusSnapshot.fromJson(json); + } catch (_) { + return null; + } + } + + /// 转换为本地缓存对象;字段名称仅用于终端缓存,不属于服务端协议。 + Map toJson() { + return { + 'printerAvailable': printerAvailable, + 'steelyardAvailable': steelyardAvailable, + 'cardReaderAvailable': cardReaderAvailable, + 'subScreenAvailable': subScreenAvailable, + 'cameraAvailable': cameraAvailable, + 'updatedAt': updatedAt?.toIso8601String(), + }; + } + + /// 编码为写入设备状态缓存的 JSON 文本。 + String toCacheString() => jsonEncode(toJson()); + + /// 复制快照并只替换本次发生变化的设备状态或检查时间。 + TerminalDeviceStatusSnapshot copyWith({ + bool? printerAvailable, + bool? steelyardAvailable, + bool? cardReaderAvailable, + bool? subScreenAvailable, + bool? cameraAvailable, + DateTime? updatedAt, + }) { + return TerminalDeviceStatusSnapshot( + printerAvailable: printerAvailable ?? this.printerAvailable, + steelyardAvailable: steelyardAvailable ?? this.steelyardAvailable, + cardReaderAvailable: cardReaderAvailable ?? this.cardReaderAvailable, + subScreenAvailable: subScreenAvailable ?? this.subScreenAvailable, + cameraAvailable: cameraAvailable ?? this.cameraAvailable, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + /// 比较全部设备可用性与更新时间,供响应式状态避免无意义更新。 + @override + bool operator ==(Object other) { + return identical(this, other) || + other is TerminalDeviceStatusSnapshot && + other.printerAvailable == printerAvailable && + other.steelyardAvailable == steelyardAvailable && + other.cardReaderAvailable == cardReaderAvailable && + other.subScreenAvailable == subScreenAvailable && + other.cameraAvailable == cameraAvailable && + other.updatedAt == updatedAt; + } + + /// 返回与相等性字段一致的哈希值。 + @override + int get hashCode => Object.hash( + printerAvailable, + steelyardAvailable, + cardReaderAvailable, + subScreenAvailable, + cameraAvailable, + updatedAt, + ); +} diff --git a/lib/common/models/mq/mq_models.dart b/lib/common/models/mq/mq_models.dart index ed79f9c..ce28e64 100644 --- a/lib/common/models/mq/mq_models.dart +++ b/lib/common/models/mq/mq_models.dart @@ -40,6 +40,23 @@ class MqttInvoiceMsg { @JsonKey(fromJson: JsonTool.anyToString, toJson: JsonTool.stringToInt) final String? androidOrderCode; + /// 实际接收消息并代理 USB 打印的来源业务终端 ID。 + @JsonKey(fromJson: JsonTool.anyToString, toJson: JsonTool.stringToInt) + final String? sourceHardId; + + /// 后台绑定的 EY_PRINT 逻辑打印机资产 ID,用于核对当前任务路由。 + @JsonKey(fromJson: JsonTool.anyToString, toJson: JsonTool.stringToInt) + final String? targetHardId; + + /// 打印内容类型,兼容发票与兑换券协议编码。 + final String? printType; + + /// 同订单同打印类型的业务打印序号;传输重试不会增加该值。 + final int? printNo; + + /// 同一服务端任务的人工重试版本,用于终端幂等去重。 + final int? retryVersion; + // 图片地址url final String? pic; @@ -52,6 +69,11 @@ class MqttInvoiceMsg { this.userId, this.orderId, this.androidOrderCode, + this.sourceHardId, + this.targetHardId, + this.printType, + this.printNo, + this.retryVersion, this.pic, this.isGlobalFace, }); @@ -62,6 +84,34 @@ class MqttInvoiceMsg { Map toJson() => _$MqttInvoiceMsgToJson(this); } +/// FACE 动作专用消息,只携带发生变化的用户标识供终端回查最新状态。 +@JsonSerializable() +class MqttFaceChangeMsg { + /// MQTT 动作,协议值固定为 FACE。 + final MqtActionEnum? action; + + /// 发生人脸 CRUD 或部门调动的用户主键。 + @JsonKey(fromJson: JsonTool.anyToString, toJson: JsonTool.stringToInt) + final String? userId; + + /// 兼容既有协议的全局人脸状态;生产部门同步不据此接收外部门人脸。 + final bool? isGlobalFace; + + /// 创建一条 FACE 变更通知。 + const MqttFaceChangeMsg({ + this.action, + this.userId, + this.isGlobalFace, + }); + + /// 从 MQTT JSON 创建专用消息。 + factory MqttFaceChangeMsg.fromJson(Map json) => + _$MqttFaceChangeMsgFromJson(json); + + /// 将专用消息转换为 JSON,主要供协议测试使用。 + Map toJson() => _$MqttFaceChangeMsgToJson(this); +} + @JsonSerializable() class MqttTakeFoodBroadcastMsg { /// 发送硬件的topic diff --git a/lib/common/models/mq/mq_models.g.dart b/lib/common/models/mq/mq_models.g.dart index 5ae0b68..452c52d 100644 --- a/lib/common/models/mq/mq_models.g.dart +++ b/lib/common/models/mq/mq_models.g.dart @@ -33,6 +33,11 @@ MqttInvoiceMsg _$MqttInvoiceMsgFromJson(Map json) => userId: JsonTool.anyToString(json['userId']), orderId: JsonTool.anyToString(json['orderId']), androidOrderCode: JsonTool.anyToString(json['androidOrderCode']), + sourceHardId: JsonTool.anyToString(json['sourceHardId']), + targetHardId: JsonTool.anyToString(json['targetHardId']), + printType: json['printType'] as String?, + printNo: (json['printNo'] as num?)?.toInt(), + retryVersion: (json['retryVersion'] as num?)?.toInt(), pic: json['pic'] as String?, isGlobalFace: json['isGlobalFace'] as bool?, ); @@ -44,10 +49,29 @@ Map _$MqttInvoiceMsgToJson(MqttInvoiceMsg instance) => 'userId': JsonTool.stringToInt(instance.userId), 'orderId': JsonTool.stringToInt(instance.orderId), 'androidOrderCode': JsonTool.stringToInt(instance.androidOrderCode), + 'sourceHardId': JsonTool.stringToInt(instance.sourceHardId), + 'targetHardId': JsonTool.stringToInt(instance.targetHardId), + 'printType': instance.printType, + 'printNo': instance.printNo, + 'retryVersion': instance.retryVersion, 'pic': instance.pic, 'isGlobalFace': instance.isGlobalFace, }; +MqttFaceChangeMsg _$MqttFaceChangeMsgFromJson(Map json) => + MqttFaceChangeMsg( + action: $enumDecodeNullable(_$MqtActionEnumEnumMap, json['action']), + userId: JsonTool.anyToString(json['userId']), + isGlobalFace: json['isGlobalFace'] as bool?, + ); + +Map _$MqttFaceChangeMsgToJson(MqttFaceChangeMsg instance) => + { + 'action': _$MqtActionEnumEnumMap[instance.action], + 'userId': JsonTool.stringToInt(instance.userId), + 'isGlobalFace': instance.isGlobalFace, + }; + MqttTakeFoodBroadcastMsg _$MqttTakeFoodBroadcastMsgFromJson( Map json) => MqttTakeFoodBroadcastMsg( diff --git a/lib/common/models/page/app_version_enum_models.dart b/lib/common/models/page/app_version_enum_models.dart index d01fbe9..4455cf6 100644 --- a/lib/common/models/page/app_version_enum_models.dart +++ b/lib/common/models/page/app_version_enum_models.dart @@ -1,28 +1,59 @@ import 'package:pj_supermarket_cloud/common/models/base/base_models.dart'; +import 'package:pj_supermarket_cloud/common/models/database/database_enum_models.dart'; +/// App 版本平台协议枚举,编码与后端 AppPlatformEnum 持久化值保持一致。 enum AppPlatformEnum implements BaseEnum { + /// 普通 Android 手机 App 平台编码。 android("1", "安卓"), + + /// 普通 iOS 手机 App 平台编码。 ios("2", "苹果"), + + /// 超市 Android 收银终端平台编码。 + supermarketAndroid("3", "超市 Android 终端"), + + /// 食堂 Android 消费终端平台编码。 + canteenAndroid("4", "食堂 Android 终端"), ; + /// 后端版本记录使用的平台编码,不得随展示文案调整。 @override final String code; + + /// 终端版本列表使用的平台中文名称。 @override final String title; + /// 创建一项固定平台编码与展示名称的映射。 const AppPlatformEnum(this.code, this.title); + + /// 根据本机业务类型返回后台版本接口使用的精确终端平台编码。 + static String terminalCode(SysType sysType) => switch (sysType) { + SysType.SUPERMARKET => supermarketAndroid.code, + SysType.CANTEEN => canteenAndroid.code, + }; } +/// App 版本发布状态,编码来源于后端版本管理接口。 enum AppVersionStatusEnum implements BaseEnum { + /// 已发布,可供匹配平台的终端查询和更新。 published("1", "已发布"), + + /// 未发布,仅供后台维护,不应出现在终端版本列表。 unpublished("2", "未发布"), + + /// 已下架,不再提供终端下载。 offShelf("3", "已下架"), ; + /// 后端版本记录使用的发布状态编码。 @override final String code; + + /// 设置页使用的发布状态中文名称。 @override final String title; + /// 创建一项固定发布状态编码与展示名称的映射。 const AppVersionStatusEnum(this.code, this.title); } diff --git a/lib/common/models/page/product_meal_period_models.dart b/lib/common/models/page/product_meal_period_models.dart new file mode 100644 index 0000000..cc22ec1 --- /dev/null +++ b/lib/common/models/page/product_meal_period_models.dart @@ -0,0 +1,163 @@ +import '../database/database_enum_models.dart'; + +/// 单个商品餐段在一天内的时间范围,采用 `[开始, 结束)` 规则避免相邻边界重叠。 +class ProductMealPeriod { + const ProductMealPeriod({ + required this.hourType, + required this.startMinutes, + required this.endMinutes, + }); + + /// 复用商品、订单和接口已有的早餐/中餐/晚餐枚举。 + final ProductHourType hourType; + + /// 从 00:00 起算的开始分钟数,允许 0 至 1439。 + final int startMinutes; + + /// 从 00:00 起算的结束分钟数,允许 1 至 1440;1440 表示 24:00。 + final int endMinutes; + + ProductMealPeriod copyWith({int? startMinutes, int? endMinutes}) { + return ProductMealPeriod( + hourType: hourType, + startMinutes: startMinutes ?? this.startMinutes, + endMinutes: endMinutes ?? this.endMinutes, + ); + } + + bool containsMinutes(int minutes) => + minutes >= startMinutes && minutes < endMinutes; + + Map toJson() => { + 'hourType': hourType.code, + 'startMinutes': startMinutes, + 'endMinutes': endMinutes, + }; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProductMealPeriod && + hourType == other.hourType && + startMinutes == other.startMinutes && + endMinutes == other.endMinutes; + + @override + int get hashCode => Object.hash(hourType, startMinutes, endMinutes); +} + +/// 每日商品餐段配置,负责默认值、持久化恢复、校验和当前餐段解析。 +class ProductMealPeriodSchedule { + ProductMealPeriodSchedule._(List periods) + : periods = List.unmodifiable(periods); + + /// 产品默认餐段;10:00 至 11:00 按需求保留为空档。 + static final ProductMealPeriodSchedule defaults = + ProductMealPeriodSchedule._(const [ + ProductMealPeriod( + hourType: ProductHourType.MORNING, + startMinutes: 5 * 60, + endMinutes: 10 * 60, + ), + ProductMealPeriod( + hourType: ProductHourType.NOON, + startMinutes: 11 * 60, + endMinutes: 16 * 60, + ), + ProductMealPeriod( + hourType: ProductHourType.NIGHT, + startMinutes: 16 * 60, + endMinutes: 24 * 60, + ), + ]); + + /// 按早餐、中餐、晚餐固定顺序保存;“查询全部”不属于可配置餐段。 + final List periods; + + /// 从 sysJson 扩展值恢复配置;旧版本、残缺或非法配置统一安全回退默认值。 + factory ProductMealPeriodSchedule.fromJson(Object? source) { + if (source is! List) return defaults; + final byCode = {for (final type in configurableHourTypes) type.code: type}; + final restored = {}; + for (final item in source) { + if (item is! Map) return defaults; + final map = Map.from(item); + final type = byCode[map['hourType']?.toString()]; + final start = _asInt(map['startMinutes']); + final end = _asInt(map['endMinutes']); + if (type == null || start == null || end == null) return defaults; + restored[type] = ProductMealPeriod( + hourType: type, + startMinutes: start, + endMinutes: end, + ); + } + if (restored.length != configurableHourTypes.length) return defaults; + final periods = configurableHourTypes + .map((type) => restored[type]!) + .toList(growable: false); + return validate(periods) == null + ? ProductMealPeriodSchedule._(periods) + : defaults; + } + + /// 仅基础三餐参与时间配置,并保持与现有枚举一致的展示顺序。 + static const configurableHourTypes = [ + ProductHourType.MORNING, + ProductHourType.NOON, + ProductHourType.NIGHT, + ]; + + /// 创建经过校验的配置;页面保存前也使用同一条规则。 + factory ProductMealPeriodSchedule.checked(List periods) { + final error = validate(periods); + if (error != null) throw ArgumentError.value(periods, 'periods', error); + return ProductMealPeriodSchedule._(periods); + } + + /// 返回首条用户可读错误;允许餐段之间留空档,但不允许乱序、交叉或越过当天边界。 + static String? validate(List periods) { + if (periods.length != configurableHourTypes.length) { + return '请完整配置早餐、中餐和晚餐'; + } + for (var index = 0; index < configurableHourTypes.length; index++) { + final period = periods[index]; + final expectedType = configurableHourTypes[index]; + if (period.hourType != expectedType) return '餐段顺序必须为早餐、中餐、晚餐'; + if (period.startMinutes < 0 || period.startMinutes >= 24 * 60) { + return '${period.hourType.title}开始时间必须在 00:00 至 23:59 之间'; + } + if (period.endMinutes <= 0 || period.endMinutes > 24 * 60) { + return '${period.hourType.title}结束时间必须在 00:01 至 24:00 之间'; + } + if (period.startMinutes % 30 != 0 || period.endMinutes % 30 != 0) { + return '${period.hourType.title}时间必须按 30 分钟递增'; + } + if (period.startMinutes >= period.endMinutes) { + return '${period.hourType.title}结束时间必须晚于开始时间'; + } + if (index > 0 && periods[index - 1].endMinutes > period.startMinutes) { + return '${periods[index - 1].hourType.title}与${period.hourType.title}时间范围不能重叠'; + } + } + return null; + } + + /// 按本地当前时间解析基础餐段;处于未配置空档时返回空,而不是“查询全部”。 + ProductHourType? resolve(DateTime time) { + final minutes = time.hour * 60 + time.minute; + for (final period in periods) { + if (period.containsMinutes(minutes)) return period.hourType; + } + return null; + } + + List> toJson() => + periods.map((period) => period.toJson()).toList(growable: false); + + static int? _asInt(Object? value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return int.tryParse(value?.toString() ?? ''); + } +} diff --git a/lib/common/models/page/reservation_menu_page_models.dart b/lib/common/models/page/reservation_menu_page_models.dart new file mode 100644 index 0000000..05239b4 --- /dev/null +++ b/lib/common/models/page/reservation_menu_page_models.dart @@ -0,0 +1,596 @@ +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import '../api/hard_api_models.dart'; + +part 'reservation_menu_page_models.g.dart'; + +/// 查询菜单支持的本人识别方式。 +/// +/// [code] 会保存到终端设置中,必须保持稳定;[serviceCode] 继续沿用后端接口契约。 +enum ReservationRecognitionMethod { + card('card', '饭卡', 'CARD'), + face('face', '人脸', 'FACE'), + ; + + const ReservationRecognitionMethod( + this.code, + this.title, + this.serviceCode, + ); + + /// 终端设置中持久化的稳定识别方式编码。 + final String code; + + /// 查询页面显示的识别方式名称。 + final String title; + + /// 提交给后端预约查询接口的识别方式编码。 + final String serviceCode; +} + +/// 查询菜单固定展示的餐段,编码与后端餐段枚举保持一致。 +enum ReservationMealSlot { + /// 早餐,后端稳定编码为 `1`。 + morning('1', '早餐'), + + /// 午餐,后端稳定编码为 `2`,同时兼容历史 `中餐` 文案。 + noon('2', '午餐'), + + /// 晚餐,后端稳定编码为 `3`。 + night('3', '晚餐'), + + /// 夜间餐,后端稳定编码为 `4`,不作为预查询常规选项。 + lateNight('4', '夜间餐'), + ; + + /// 创建与后端餐段编码绑定的展示枚举,编码属于跨端接口契约,不得随 UI 文案调整。 + const ReservationMealSlot(this.code, this.title); + + /// 查询前允许操作员预选的基础三餐,不包含仅按后端结果补充展示的夜间餐。 + static const List querySelectableValues = [ + ReservationMealSlot.morning, + ReservationMealSlot.noon, + ReservationMealSlot.night, + ]; + + /// 后端餐段契约中的稳定编码。 + final String code; + + /// 查询卡片展示的餐段名称。 + final String title; + + /// 判断后端餐段数据是否属于当前枚举,兼容数字编码、英文历史值和中午餐文案差异。 + bool matches(ReservationMenuMealData meal) { + final hourType = meal.hourType.trim().toUpperCase().replaceAll('-', '_'); + final matchesCode = switch (this) { + ReservationMealSlot.morning => + const {'1', 'MORNING', 'BREAKFAST'}.contains(hourType), + ReservationMealSlot.noon => + const {'2', 'NOON', 'NOONING', 'LUNCH'}.contains(hourType), + ReservationMealSlot.night => + const {'3', 'NIGHT', 'DINNER'}.contains(hourType), + ReservationMealSlot.lateNight => + const {'4', 'LATE_NIGHT', 'SUPPER'}.contains(hourType), + }; + return matchesCode || meal.title.trim() == title; + } +} + +/// 核销按钮允许出现的预约日期范围。 +enum ReservationVerificationDateScope { + today('today', '今天'), + all('all', '所有日期'), + ; + + const ReservationVerificationDateScope(this.code, this.title); + + /// 终端设置中持久化的核销日期范围编码。 + final String code; + + /// 页面展示的核销日期范围名称。 + final String title; +} + +/// 查询菜单在本机保存的页面交互偏好,不改变预约接口和硬件协议。 +@immutable +class ReservationQueryPagePreferences { + /// 创建查询菜单偏好;未显式保存的布尔配置使用兼容旧终端的安全默认值。 + const ReservationQueryPagePreferences({ + required this.methodOrder, + required this.defaultMethod, + this.autoRecognitionEnabled = false, + this.continuousRecognitionEnabled = true, + this.nameMaskEnabled = true, + this.verificationEnabled = false, + this.quickVerificationEnabled = false, + this.allowManualMealPeriodChange = true, + this.selectedMealSlot = ReservationMealSlot.morning, + this.verificationDateScope = ReservationVerificationDateScope.today, + }); + + /// 未保存页面设置时使用的默认查询偏好。 + static const defaults = ReservationQueryPagePreferences( + methodOrder: ReservationRecognitionMethod.values, + defaultMethod: ReservationRecognitionMethod.card, + autoRecognitionEnabled: false, + continuousRecognitionEnabled: true, + nameMaskEnabled: true, + verificationEnabled: false, + quickVerificationEnabled: false, + allowManualMealPeriodChange: true, + selectedMealSlot: ReservationMealSlot.morning, + verificationDateScope: ReservationVerificationDateScope.today, + ); + + /// 查询方式的页面展示顺序。 + final List methodOrder; + + /// 页面首次进入时默认选中的查询方式。 + final ReservationRecognitionMethod defaultMethod; + + /// 是否在进入页面和切换查询方式后自动启动对应识别设备,默认关闭以保持旧终端行为。 + final bool autoRecognitionEnabled; + + /// 自动识别等待超时后是否继续开启下一轮,默认开启以满足无人值守终端持续接待需求。 + /// + /// 人脸查询当前没有无人超时,会话本身会持续运行;该配置同时作为未来人脸超时事件的续轮策略。 + final bool continuousRecognitionEnabled; + + /// 查询结果中的用户姓名是否按服务端规则脱敏,默认开启以兼容旧终端。 + final bool nameMaskEnabled; + + /// 查询结果是否显示预约订单核销入口。 + final bool verificationEnabled; + + /// 识别查询成功后是否直接打开今天预选餐段的核销弹框,默认关闭以保持旧终端流程。 + final bool quickVerificationEnabled; + + /// 是否允许操作员在查询菜单主动修改餐段;关闭后严格按餐段配置时间自动切换。 + final bool allowManualMealPeriodChange; + + /// 查询菜单最近一次选中的餐段,旧终端未保存时默认早餐。 + final ReservationMealSlot selectedMealSlot; + + /// 允许显示核销入口的预约日期范围。 + final ReservationVerificationDateScope verificationDateScope; +} + +/// 预约订单中的套餐组成展示模型,统一处理缺失名称和异常份数。 +@immutable +class ReservationMenuComboItemData { + /// 创建已规范化的套餐组成展示数据。 + const ReservationMenuComboItemData({ + required this.childSpuId, + required this.name, + this.picUrl, + required this.quantity, + this.sortOrder, + this.price, + }); + + /// 将接口套餐组成转换为页面模型,并补齐名称与正数份数。 + factory ReservationMenuComboItemData.fromService( + ServiceReservationComboItem item, + ) { + final normalizedName = item.name?.trim(); + final normalizedQuantity = + item.quantity != null && item.quantity! > 0 ? item.quantity! : 1; + return ReservationMenuComboItemData( + childSpuId: item.childSpuId, + name: normalizedName?.isNotEmpty == true ? normalizedName! : '未命名菜品', + picUrl: item.picUrl, + quantity: normalizedQuantity, + sortOrder: item.sortOrder, + price: item.price, + ); + } + + /// 套餐组成菜品 SPU ID,可能因历史数据缺失为空。 + final int? childSpuId; + + /// 组成菜品展示名称,始终包含可读兜底文案。 + final String name; + + /// 组成菜品封面地址,未配置时为空。 + final String? picUrl; + + /// 套餐中该组成菜品的份数,已收敛为正整数。 + final int quantity; + + /// 服务端配置的展示顺序,缺失时按原列表顺序。 + final int? sortOrder; + + /// 组成菜品展示单价,单位为分,缺失时不展示。 + final int? price; + + /// 按全部展示字段判断两个套餐组成是否一致。 + @override + bool operator ==(Object other) => + other is ReservationMenuComboItemData && + other.childSpuId == childSpuId && + other.name == name && + other.picUrl == picUrl && + other.quantity == quantity && + other.sortOrder == sortOrder && + other.price == price; + + /// 生成与相等判断字段一致的哈希值。 + @override + int get hashCode => + Object.hash(childSpuId, name, picUrl, quantity, sortOrder, price); +} + +/// 单笔预约订单中的商品展示模型,包含可选套餐组成。 +@immutable +class ReservationMenuOrderItemData { + /// 创建订单商品展示数据,新字段默认兼容旧服务端的单品结构。 + const ReservationMenuOrderItemData({ + required this.productName, + this.productId, + this.productPic, + this.productCount, + this.spuType = 1, + this.comboItems = const [], + }); + + /// 将接口订单商品转换为不可变页面模型,并按配置顺序排列套餐组成。 + factory ReservationMenuOrderItemData.fromService( + ServiceReservationOrderItem item, + ) { + final comboItems = item.comboItems.indexed + .map( + (entry) => ( + sourceIndex: entry.$1, + item: ReservationMenuComboItemData.fromService(entry.$2), + ), + ) + .toList() + ..sort((left, right) { + final leftOrder = left.item.sortOrder ?? 1 << 30; + final rightOrder = right.item.sortOrder ?? 1 << 30; + final orderComparison = leftOrder.compareTo(rightOrder); + return orderComparison != 0 + ? orderComparison + : left.sourceIndex.compareTo(right.sourceIndex); + }); + return ReservationMenuOrderItemData( + productName: item.productName, + productId: item.productId, + productPic: item.productPic, + productCount: item.productCount, + spuType: item.spuType, + comboItems: List.unmodifiable(comboItems.map((entry) => entry.item)), + ); + } + + /// 订单中的菜品库商品 SPU ID;旧服务端未返回时为空。 + final int? productId; + + /// 当前订单实际购买的商品名称。 + final String productName; + + /// 当前订单实际购买的商品图片地址。 + final String? productPic; + + /// 当前订单实际购买的商品数量;历史异常数据缺失时允许为空。 + final int? productCount; + + /// 商品类型:1 为单品,2 为套餐。 + final int spuType; + + /// 套餐组成明细,单品时为空;列表不可变以避免弹框交互修改订单模型。 + final List comboItems; + + /// 判断当前商品是否为套餐,接口值异常时按单品处理。 + bool get isCombo => spuType == 2; + + /// 按订单商品及套餐组成全部展示字段判断两个模型是否一致。 + @override + bool operator ==(Object other) => + other is ReservationMenuOrderItemData && + other.productId == productId && + other.productName == productName && + other.productPic == productPic && + other.productCount == productCount && + other.spuType == spuType && + listEquals(other.comboItems, comboItems); + + /// 生成与相等判断字段一致的哈希值。 + @override + int get hashCode => Object.hash( + productId, + productName, + productPic, + productCount, + spuType, + Object.hashAll(comboItems), + ); +} + +@immutable +class ReservationMenuOrderData { + const ReservationMenuOrderData({ + required this.orderId, + required this.items, + }); + + factory ReservationMenuOrderData.fromService( + ServiceReservationOrder order, + ) { + return ReservationMenuOrderData( + orderId: order.orderId, + items: List.unmodifiable( + order.items.map(ReservationMenuOrderItemData.fromService), + ), + ); + } + + /// 可核销的预约订单 ID。 + final int orderId; + + /// 该笔预约订单实际购买的商品与数量明细。 + final List items; + + @override + bool operator ==(Object other) => + other is ReservationMenuOrderData && + other.orderId == orderId && + listEquals(other.items, items); + + @override + int get hashCode => Object.hash(orderId, Object.hashAll(items)); +} + +@immutable +@JsonSerializable() +class ReservationMenuMealData { + const ReservationMenuMealData({ + required this.hourType, + required this.title, + required this.items, + this.orderIds = const [], + this.orders = const [], + this.reserved = true, + }); + + factory ReservationMenuMealData.fromService(ServiceReservationMeal meal) { + return ReservationMenuMealData( + hourType: meal.hourType, + title: meal.mealTitle, + items: List.unmodifiable(meal.items), + orderIds: List.unmodifiable(meal.orderIds), + orders: List.unmodifiable( + meal.orders.map(ReservationMenuOrderData.fromService), + ), + ); + } + + factory ReservationMenuMealData.fromJson(Map json) => + _$ReservationMenuMealDataFromJson(json); + + /// 预约餐段编码。 + final String hourType; + + /// 预约餐段显示名称,JSON 字段沿用副屏协议中的 mealTitle。 + @JsonKey(name: 'mealTitle') + final String title; + + /// 当前餐段的去重餐品汇总,仅用于菜单卡片展示。 + final List items; + + /// 当前餐段可核销的预约订单 ID;副屏展示消息禁止携带。 + @JsonKey(includeFromJson: false, includeToJson: false) + final List orderIds; + + /// 当前餐段每笔订单与餐品的对应关系;仅主屏核销确认使用。 + @JsonKey(includeFromJson: false, includeToJson: false) + final List orders; + + /// 当前餐段是否存在有效预约,用于区分预约数据和补齐的空餐段。 + @JsonKey(includeFromJson: false, includeToJson: false) + final bool reserved; + + Map toJson() => _$ReservationMenuMealDataToJson(this); + + /// 逐单详情只使用服务端订单级商品,禁止把餐段汇总数据冒充为某笔订单明细。 + List itemsForOrder(int orderId) { + for (final order in orders) { + if (order.orderId == orderId) return order.items; + } + return const []; + } + + /// 只有订单级明细才能证明餐品属于该订单,禁止使用餐段汇总数据冒充。 + bool hasItemsForOrder(int orderId) { + return orders.any((order) => order.orderId == orderId); + } + + @override + bool operator ==(Object other) => + other is ReservationMenuMealData && + other.hourType == hourType && + other.title == title && + other.reserved == reserved && + listEquals(other.items, items) && + listEquals(other.orderIds, orderIds) && + listEquals(other.orders, orders); + + @override + int get hashCode => Object.hash(hourType, title, reserved, + Object.hashAll(items), Object.hashAll(orderIds), Object.hashAll(orders)); +} + +@immutable +@JsonSerializable(explicitToJson: true) +class ReservationMenuDayData { + const ReservationMenuDayData({ + required this.date, + required this.meals, + }); + + factory ReservationMenuDayData.fromService(ServiceReservationDay day) { + return ReservationMenuDayData( + date: day.date, + meals: List.unmodifiable( + day.meals.map(ReservationMenuMealData.fromService), + ), + ); + } + + factory ReservationMenuDayData.fromJson(Map json) => + _$ReservationMenuDayDataFromJson(json); + + /// 预约日期,格式为 yyyy-MM-dd。 + @JsonKey(fromJson: _validReservationDate) + final String date; + + /// 当天后端实际返回的预约餐段列表。 + final List meals; + + /// 查询卡片始终展示的基础三餐。 + static const _baseDisplaySlots = [ + ReservationMealSlot.morning, + ReservationMealSlot.noon, + ReservationMealSlot.night, + ]; + + /// 基础三餐始终展示;夜间餐不是常规餐段,只有后端实际返回数据时才追加。 + List get displayMeals { + final displaySlots = [ + ..._baseDisplaySlots, + if (meals.any(ReservationMealSlot.lateNight.matches)) + ReservationMealSlot.lateNight, + ]; + + return List.unmodifiable( + displaySlots.map((slot) { + ReservationMenuMealData? matched; + for (final meal in meals) { + if (slot.matches(meal)) { + matched = meal; + break; + } + } + if (matched == null) { + return ReservationMenuMealData( + hourType: slot.code, + title: slot.title, + items: const [], + reserved: false, + ); + } + return ReservationMenuMealData( + hourType: slot.code, + title: slot.title, + items: matched.items, + orderIds: matched.orderIds, + orders: matched.orders, + ); + }), + ); + } + + DateTime get dateTime => DateTime.parse(date); + + bool isToday([DateTime? now]) { + final today = now ?? DateTime.now(); + return dateTime.year == today.year && + dateTime.month == today.month && + dateTime.day == today.day; + } + + String get monthDay => + '${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')}'; + + String get weekLabel => const [ + '周一', + '周二', + '周三', + '周四', + '周五', + '周六', + '周日', + ][dateTime.weekday - 1]; + + Map toJson() => _$ReservationMenuDayDataToJson(this); + + @override + bool operator ==(Object other) => + other is ReservationMenuDayData && + other.date == date && + listEquals(other.meals, meals); + + @override + int get hashCode => Object.hash(date, Object.hashAll(meals)); +} + +/// 副屏消息必须携带合法预约日期,避免生成式反序列化放宽原有输入校验。 +String _validReservationDate(Object? value) { + final date = value?.toString() ?? ''; + if (DateTime.tryParse(date) == null) { + throw const FormatException('预约日期格式错误'); + } + return date; +} + +@immutable +class ReservationMenuDisplayData { + const ReservationMenuDisplayData({ + required this.maskedName, + required this.merchantName, + required this.totalDayCount, + required this.totalMealCount, + required this.days, + this.deptName, + }); + + factory ReservationMenuDisplayData.fromService( + ServiceReservationMenuQuery result, + ) { + return ReservationMenuDisplayData( + maskedName: result.maskedName, + deptName: result.deptName, + merchantName: result.merchantName, + totalDayCount: result.totalDayCount, + totalMealCount: result.totalMealCount, + days: List.unmodifiable( + result.days.map(ReservationMenuDayData.fromService)), + ); + } + + /// 服务端按终端配置返回的用户姓名;字段名保留 maskedName 以兼容既有协议。 + final String maskedName; + + /// 用户所属部门名称。 + final String? deptName; + + /// 当前终端绑定的食堂名称。 + final String merchantName; + + /// 查询结果中包含预约的日期数量。 + final int totalDayCount; + + /// 查询结果中包含预约的餐段数量。 + final int totalMealCount; + + /// 按日期升序排列的预约菜单列表。 + final List days; + + /// 单页最多展示的预约日期数量。 + static const pageSize = 10; + + int get totalPages => + days.isEmpty ? 1 : (days.length / pageSize).ceil().clamp(1, 1 << 20); + + List pageDays(int page) { + final normalized = page.clamp(1, totalPages); + final start = (normalized - 1) * pageSize; + return List.unmodifiable( + days.skip(start).take(pageSize), + ); + } + + String get dateRange => + days.isEmpty ? '暂无预约日期' : '${days.first.date} 至 ${days.last.date}'; +} diff --git a/lib/common/models/page/reservation_menu_page_models.g.dart b/lib/common/models/page/reservation_menu_page_models.g.dart new file mode 100644 index 0000000..8865778 --- /dev/null +++ b/lib/common/models/page/reservation_menu_page_models.g.dart @@ -0,0 +1,40 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'reservation_menu_page_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ReservationMenuMealData _$ReservationMenuMealDataFromJson( + Map json) => + ReservationMenuMealData( + hourType: json['hourType'] as String, + title: json['mealTitle'] as String, + items: (json['items'] as List).map((e) => e as String).toList(), + ); + +Map _$ReservationMenuMealDataToJson( + ReservationMenuMealData instance) => + { + 'hourType': instance.hourType, + 'mealTitle': instance.title, + 'items': instance.items, + }; + +ReservationMenuDayData _$ReservationMenuDayDataFromJson( + Map json) => + ReservationMenuDayData( + date: _validReservationDate(json['date']), + meals: (json['meals'] as List) + .map((e) => + ReservationMenuMealData.fromJson(e as Map)) + .toList(), + ); + +Map _$ReservationMenuDayDataToJson( + ReservationMenuDayData instance) => + { + 'date': instance.date, + 'meals': instance.meals.map((e) => e.toJson()).toList(), + }; diff --git a/lib/common/models/page/setting_page_enum_models.dart b/lib/common/models/page/setting_page_enum_models.dart index aabb5f5..dd01f49 100644 --- a/lib/common/models/page/setting_page_enum_models.dart +++ b/lib/common/models/page/setting_page_enum_models.dart @@ -7,9 +7,10 @@ enum SettingMenuType implements BaseEnum { syncSetting("20", "同步设置"), paySetting("30", "支付设置"), backup("40", "资源备份"), - faceSetting("50", "支付设置"), - productSetting("60", "资源备份"), + faceSetting("50", "人脸管理"), + productSetting("60", "商品管理"), versionSetting("70", "版本管理"), + facePluginSetting("80", "人脸设置"), ; @override @@ -48,3 +49,51 @@ enum SettingGlobalFontSize implements BaseEnum { const SettingGlobalFontSize(this.code, this.title, this.fontSize); } + +enum SettingButtonRadius implements BaseEnum { + compact("compact", "紧凑", 8), + standard("standard", "标准", 12), + large("large", "大号", 16), + ; + + @override + final String code; + @override + final String title; + + final double radius; + + const SettingButtonRadius(this.code, this.title, this.radius); +} + +enum SettingCardRadius implements BaseEnum { + compact("compact", "紧凑", 12), + standard("standard", "标准", 16), + large("large", "大号", 20), + ; + + @override + final String code; + @override + final String title; + + final double radius; + + const SettingCardRadius(this.code, this.title, this.radius); +} + +enum SettingButtonSize implements BaseEnum { + compact("compact", "紧凑", 0.88), + standard("standard", "标准", 1), + large("large", "大号", 1.12), + ; + + @override + final String code; + @override + final String title; + + final double scale; + + const SettingButtonSize(this.code, this.title, this.scale); +} diff --git a/lib/common/models/plugs/face_discern_models.dart b/lib/common/models/plugs/face_discern_models.dart index 6030d4a..a2d8cac 100644 --- a/lib/common/models/plugs/face_discern_models.dart +++ b/lib/common/models/plugs/face_discern_models.dart @@ -4,24 +4,51 @@ part 'face_discern_models.g.dart'; @JsonSerializable() class FaceStream { - // 相似度 + /// 本次比对相似度,范围为 0 到 1。 double similar; - // 识别时间 + /// 虹软完成本次比对耗时,单位为毫秒。 int cost; - // 人脸id + /// 本地虹软人脸库记录编号,旧协议可能为空。 int? faceId; - // 人脸样本id + /// 服务端同步到终端的人脸样本编号。 int? sampleId; - // 样本id + /// 识别成功人员的服务端用户编号。 String? userId; - // 用户名 + /// 识别成功人员的展示姓名。 String? userName; + /// 原生会话编号,用于把一次成功识别绑定到唯一订单;旧原生插件为空。 + String? sessionId; + + /// 实际命中比对的 NV21 帧采集时间,单位为 Unix 毫秒。 + int? capturedAt; + + /// 原生裁剪后的现场 JPEG 私有路径;该路径只在终端本地队列使用。 + String? evidenceFilePath; + + /// 现场 JPEG 的 SHA-256 摘要,文件缺失或裁剪失败时为空。 + String? evidenceHash; + + /// 现场 JPEG 大小,单位为字节。 + int? evidenceSize; + + /// 现场 JPEG 宽度,单位为像素。 + int? evidenceWidth; + + /// 现场 JPEG 高度,单位为像素。 + int? evidenceHeight; + + /// 本次人脸比对采用的阈值快照,范围为 0 到 1。 + double? similarityThreshold; + + /// 现场证据生成失败的安全摘要,不得包含完整路径、原始帧或图片内容。 + String? evidenceError; + FaceStream({ required this.similar, required this.cost, @@ -29,6 +56,15 @@ class FaceStream { this.sampleId, this.userId, this.userName, + this.sessionId, + this.capturedAt, + this.evidenceFilePath, + this.evidenceHash, + this.evidenceSize, + this.evidenceWidth, + this.evidenceHeight, + this.similarityThreshold, + this.evidenceError, }); factory FaceStream.fromJson(Map json) => @@ -61,6 +97,204 @@ class FaceConfig { Map toJson() => _$FaceConfigToJson(this); } +/// 终端本机的人脸识别策略,对应 Android `RecognizeConfiguration` 的可调白名单。 +class FaceRecognitionConfiguration { + const FaceRecognitionConfiguration({ + required this.enableLiveness, + required this.similarThreshold, + required this.rgbLivenessThreshold, + required this.enableImageQuality, + required this.imageQualityNoMaskRecognizeThreshold, + required this.imageQualityMaskRecognizeThreshold, + required this.libraryFaceQualityThreshold, + required this.keepMaxFace, + required this.maxDetectFaces, + required this.extractRetryCount, + required this.livenessRetryCount, + required this.recognizeFailedRetryInterval, + required this.livenessFailedRetryInterval, + required this.livenessType, + }); + + const FaceRecognitionConfiguration.defaults() + : enableLiveness = false, + similarThreshold = 0.8, + rgbLivenessThreshold = 0.5, + enableImageQuality = true, + imageQualityNoMaskRecognizeThreshold = 0.49, + imageQualityMaskRecognizeThreshold = 0.29, + libraryFaceQualityThreshold = 0.60, + keepMaxFace = true, + maxDetectFaces = 1, + extractRetryCount = 3, + livenessRetryCount = 3, + recognizeFailedRetryInterval = 0, + livenessFailedRetryInterval = 0, + livenessType = 'RGB'; + + final bool enableLiveness; + final double similarThreshold; + final double rgbLivenessThreshold; + final bool enableImageQuality; + final double imageQualityNoMaskRecognizeThreshold; + final double imageQualityMaskRecognizeThreshold; + + /// 在库照片诊断阈值;只决定低质量列表分类,不阻止人脸同步或参与识别。 + final double libraryFaceQualityThreshold; + final bool keepMaxFace; + final int maxDetectFaces; + final int extractRetryCount; + final int livenessRetryCount; + final int recognizeFailedRetryInterval; + final int livenessFailedRetryInterval; + final String livenessType; + + factory FaceRecognitionConfiguration.fromMap(Map map) { + const defaults = FaceRecognitionConfiguration.defaults(); + return FaceRecognitionConfiguration( + enableLiveness: _asBoolOr(map['enableLiveness'], defaults.enableLiveness), + similarThreshold: + _asDouble(map['similarThreshold'], defaults.similarThreshold), + rgbLivenessThreshold: _asDouble( + map['rgbLivenessThreshold'], + defaults.rgbLivenessThreshold, + ), + enableImageQuality: + _asBoolOr(map['enableImageQuality'], defaults.enableImageQuality), + imageQualityNoMaskRecognizeThreshold: _asDouble( + map['imageQualityNoMaskRecognizeThreshold'], + defaults.imageQualityNoMaskRecognizeThreshold, + ), + imageQualityMaskRecognizeThreshold: _asDouble( + map['imageQualityMaskRecognizeThreshold'], + defaults.imageQualityMaskRecognizeThreshold, + ), + libraryFaceQualityThreshold: _asDouble( + map['libraryFaceQualityThreshold'], + defaults.libraryFaceQualityThreshold, + ).clamp(0.0, 1.0).toDouble(), + keepMaxFace: _asBoolOr(map['keepMaxFace'], defaults.keepMaxFace), + maxDetectFaces: _asIntOr(map['maxDetectFaces'], defaults.maxDetectFaces), + extractRetryCount: + _asIntOr(map['extractRetryCount'], defaults.extractRetryCount), + livenessRetryCount: + _asIntOr(map['livenessRetryCount'], defaults.livenessRetryCount), + recognizeFailedRetryInterval: _asIntOr( + map['recognizeFailedRetryInterval'], + defaults.recognizeFailedRetryInterval, + ), + livenessFailedRetryInterval: _asIntOr( + map['livenessFailedRetryInterval'], + defaults.livenessFailedRetryInterval, + ), + livenessType: map['livenessType']?.toString() ?? defaults.livenessType, + ); + } + + Map toMap() => { + 'enableLiveness': enableLiveness, + 'similarThreshold': similarThreshold, + 'rgbLivenessThreshold': rgbLivenessThreshold, + 'enableImageQuality': enableImageQuality, + 'imageQualityNoMaskRecognizeThreshold': + imageQualityNoMaskRecognizeThreshold, + 'imageQualityMaskRecognizeThreshold': + imageQualityMaskRecognizeThreshold, + 'libraryFaceQualityThreshold': + libraryFaceQualityThreshold.clamp(0.0, 1.0).toDouble(), + 'keepMaxFace': keepMaxFace, + 'maxDetectFaces': maxDetectFaces, + 'extractRetryCount': extractRetryCount, + 'livenessRetryCount': livenessRetryCount, + 'recognizeFailedRetryInterval': recognizeFailedRetryInterval, + 'livenessFailedRetryInterval': livenessFailedRetryInterval, + 'livenessType': livenessType, + }; + + bool get usesDefaults => + this == const FaceRecognitionConfiguration.defaults(); + + FaceRecognitionConfiguration copyWith({ + bool? enableLiveness, + double? similarThreshold, + double? rgbLivenessThreshold, + bool? enableImageQuality, + double? imageQualityNoMaskRecognizeThreshold, + double? imageQualityMaskRecognizeThreshold, + double? libraryFaceQualityThreshold, + bool? keepMaxFace, + int? maxDetectFaces, + int? extractRetryCount, + int? livenessRetryCount, + int? recognizeFailedRetryInterval, + int? livenessFailedRetryInterval, + }) { + return FaceRecognitionConfiguration( + enableLiveness: enableLiveness ?? this.enableLiveness, + similarThreshold: similarThreshold ?? this.similarThreshold, + rgbLivenessThreshold: rgbLivenessThreshold ?? this.rgbLivenessThreshold, + enableImageQuality: enableImageQuality ?? this.enableImageQuality, + imageQualityNoMaskRecognizeThreshold: + imageQualityNoMaskRecognizeThreshold ?? + this.imageQualityNoMaskRecognizeThreshold, + imageQualityMaskRecognizeThreshold: imageQualityMaskRecognizeThreshold ?? + this.imageQualityMaskRecognizeThreshold, + libraryFaceQualityThreshold: + (libraryFaceQualityThreshold ?? this.libraryFaceQualityThreshold) + .clamp(0.0, 1.0) + .toDouble(), + keepMaxFace: keepMaxFace ?? this.keepMaxFace, + maxDetectFaces: maxDetectFaces ?? this.maxDetectFaces, + extractRetryCount: extractRetryCount ?? this.extractRetryCount, + livenessRetryCount: livenessRetryCount ?? this.livenessRetryCount, + recognizeFailedRetryInterval: + recognizeFailedRetryInterval ?? this.recognizeFailedRetryInterval, + livenessFailedRetryInterval: + livenessFailedRetryInterval ?? this.livenessFailedRetryInterval, + livenessType: livenessType, + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FaceRecognitionConfiguration && + enableLiveness == other.enableLiveness && + similarThreshold == other.similarThreshold && + rgbLivenessThreshold == other.rgbLivenessThreshold && + enableImageQuality == other.enableImageQuality && + imageQualityNoMaskRecognizeThreshold == + other.imageQualityNoMaskRecognizeThreshold && + imageQualityMaskRecognizeThreshold == + other.imageQualityMaskRecognizeThreshold && + libraryFaceQualityThreshold == other.libraryFaceQualityThreshold && + keepMaxFace == other.keepMaxFace && + maxDetectFaces == other.maxDetectFaces && + extractRetryCount == other.extractRetryCount && + livenessRetryCount == other.livenessRetryCount && + recognizeFailedRetryInterval == other.recognizeFailedRetryInterval && + livenessFailedRetryInterval == other.livenessFailedRetryInterval && + livenessType == other.livenessType; + + @override + int get hashCode => Object.hash( + enableLiveness, + similarThreshold, + rgbLivenessThreshold, + enableImageQuality, + imageQualityNoMaskRecognizeThreshold, + imageQualityMaskRecognizeThreshold, + libraryFaceQualityThreshold, + keepMaxFace, + maxDetectFaces, + extractRetryCount, + livenessRetryCount, + recognizeFailedRetryInterval, + livenessFailedRetryInterval, + livenessType, + ); +} + @JsonSerializable() class RegisterAllStorageFaceRes { // 注册成功数量 @@ -107,3 +341,428 @@ class FaceRes { Map toJson() => _$FaceResToJson(this); } + +/// 原生图片特征提取结果;提取阶段不修改 SQLite 或虹软内存库。 +class FaceFeatureExtractionResult { + /// 创建一次原生特征提取结果。 + const FaceFeatureExtractionResult({ + required this.success, + required this.message, + required this.featureData, + this.qualityStatus = FaceQualityStatus.pending, + this.qualityScore, + this.qualityErrorCode, + }); + + /// 从 MethodChannel 返回映射创建特征提取结果。 + factory FaceFeatureExtractionResult.fromMap(Map map) { + final rawFeature = map['featureData']; + return FaceFeatureExtractionResult( + success: map['success'] == true, + message: map['message']?.toString() ?? '人脸特征提取失败', + featureData: rawFeature is List + ? rawFeature.map((item) => (item as num).toInt()).toList() + : const [], + qualityStatus: FaceQualityStatus.fromCode( + map['qualityStatus']?.toString(), + ), + qualityScore: (map['qualityScore'] as num?)?.toDouble(), + qualityErrorCode: (map['qualityErrorCode'] as num?)?.toInt(), + ); + } + + /// 是否成功提取且返回非空特征。 + final bool success; + + /// 不包含图片路径和特征内容的安全结果摘要。 + final String message; + + /// 虹软注册特征字节;仅在进程内传递给增量应用通道。 + final List featureData; + + /// 静态图片质量检测状态;失败不改变 [success] 的特征提取语义。 + final FaceQualityStatus qualityStatus; + + /// 虹软图片质量分,范围为 0 到 1;未检测或检测失败时为空。 + final double? qualityScore; + + /// 虹软质量检测错误码;插件内部错误使用负值,成功时为空。 + final int? qualityErrorCode; +} + +/// 在库人脸质量状态的跨端持久化协议。 +enum FaceQualityStatus { + /// 历史数据尚未执行质量检测。 + pending('pending'), + + /// 虹软已返回有效的 0 到 1 质量分。 + detected('detected'), + + /// 图片、授权或 SDK 异常导致本次未取得有效质量分。 + failed('failed'); + + /// 创建一个数据库和 MethodChannel 共用的稳定状态值。 + const FaceQualityStatus(this.code); + + /// 写入 SQLite 和原生通道的稳定编码,不依赖 Dart 枚举名称。 + final String code; + + /// 将未知或空状态兼容为待检测,避免旧版本数据被误判为低质量。 + static FaceQualityStatus fromCode(String? code) { + return FaceQualityStatus.values.firstWhere( + (value) => value.code == code, + orElse: () => FaceQualityStatus.pending, + ); + } +} + +/// 提交给 Android 原生批量检测的一条本地图片引用。 +class FaceQualityAuditItem { + /// 创建一条仅在进程内传递的样本与本地文件映射。 + const FaceQualityAuditItem({ + required this.sampleId, + required this.filePath, + }); + + /// 服务端同步到终端的虹软样本编号。 + final String sampleId; + + /// 终端私有存储中的图片绝对路径;只传给本机原生层,不写日志或上报。 + final String filePath; + + /// 转换为 StandardMessageCodec 支持的原生参数。 + Map toMap() => { + 'sampleId': sampleId, + 'filePath': filePath, + }; +} + +/// 原生批量质量检测中的单条结果。 +class FaceImageQualityResult { + /// 创建一条只包含样本编号和诊断信息的质量结果。 + const FaceImageQualityResult({ + required this.sampleId, + required this.status, + required this.message, + this.score, + this.errorCode, + }); + + /// 从 Android StandardMessageCodec 映射解析单条结果。 + factory FaceImageQualityResult.fromMap(Map map) => + FaceImageQualityResult( + sampleId: map['sampleId']?.toString() ?? '', + status: FaceQualityStatus.fromCode(map['status']?.toString()), + message: map['message']?.toString() ?? '图片质量检测失败', + score: (map['score'] as num?)?.toDouble(), + errorCode: (map['errorCode'] as num?)?.toInt(), + ); + + /// 服务端同步到终端的虹软样本编号。 + final String sampleId; + + /// 本次图片质量检测状态。 + final FaceQualityStatus status; + + /// 不包含文件路径和人员信息的安全结果摘要。 + final String message; + + /// 虹软质量分,检测成功时范围为 0 到 1。 + final double? score; + + /// 虹软或插件内部错误码,检测成功时为空。 + final int? errorCode; +} + +/// 原生批量质量检测的实时进度。 +class FaceQualityAuditProgress { + /// 创建一份可供设置页展示的批量检测进度快照。 + const FaceQualityAuditProgress({ + required this.processed, + required this.total, + required this.successCount, + required this.failedCount, + required this.inProgress, + required this.message, + this.currentSampleId, + }); + + /// 从质量检测事件通道解析进度快照。 + factory FaceQualityAuditProgress.fromMap(Map map) => + FaceQualityAuditProgress( + processed: (map['processed'] as num?)?.toInt() ?? 0, + total: (map['total'] as num?)?.toInt() ?? 0, + successCount: (map['successCount'] as num?)?.toInt() ?? 0, + failedCount: (map['failedCount'] as num?)?.toInt() ?? 0, + inProgress: map['inProgress'] == true, + message: map['message']?.toString() ?? '等待检测', + currentSampleId: map['currentSampleId']?.toString(), + ); + + /// 已完成检测的样本数量。 + final int processed; + + /// 本轮提交给原生层的样本总数。 + final int total; + + /// 已取得有效质量分的样本数量。 + final int successCount; + + /// 未取得有效质量分的样本数量。 + final int failedCount; + + /// 原生批量任务是否仍持有人脸操作互斥锁。 + final bool inProgress; + + /// 当前阶段的安全展示文案。 + final String message; + + /// 当前处理样本编号;空值表示尚未开始或已经结束。 + final String? currentSampleId; + + /// 供进度条使用的 0 到 1 比例,空任务视为 0。 + double get fraction => + total <= 0 ? 0.0 : (processed / total).clamp(0.0, 1.0).toDouble(); +} + +/// 原生批量质量检测完成结果。 +class FaceQualityAuditResult { + /// 创建一轮不可变的质量检测结果集合。 + const FaceQualityAuditResult({ + required this.results, + required this.message, + }); + + /// 从 MethodChannel 返回映射解析全部单条结果。 + factory FaceQualityAuditResult.fromMap(Map map) { + final rawResults = map['results']; + return FaceQualityAuditResult( + results: rawResults is List + ? rawResults + .whereType() + .map(FaceImageQualityResult.fromMap) + .toList(growable: false) + : const [], + message: map['message']?.toString() ?? '在库人脸质量检测完成', + ); + } + + /// 与请求样本一一对应的检测结果。 + final List results; + + /// 本轮检测完成后的安全摘要。 + final String message; +} + +/// 原生单样本人脸增删结果。 +class FaceDeltaApplyResult { + /// 创建一次原生增量应用结果。 + const FaceDeltaApplyResult({ + required this.success, + required this.message, + required this.code, + }); + + /// 从 MethodChannel 返回映射创建增量结果。 + factory FaceDeltaApplyResult.fromMap(Map map) => + FaceDeltaApplyResult( + success: map['success'] == true, + message: map['message']?.toString() ?? '人脸库增量更新失败', + code: (map['code'] as num?)?.toInt() ?? -99, + ); + + /// 虹软内存库是否已完成目标状态。 + final bool success; + + /// 不包含用户姓名、图片地址或特征的安全结果摘要。 + final String message; + + /// 虹软 SDK 返回码;插件异常使用内部负值。 + final int code; +} + +/// 原生人脸插件、授权文件和内存人脸库的实时状态。 +class FacePluginStatus { + const FacePluginStatus({ + required this.activated, + required this.activationCode, + required this.parametersComplete, + required this.engineReady, + required this.engineCode, + required this.databaseCount, + required this.memoryCount, + required this.registrationInProgress, + required this.libraryConsistent, + this.activationStartTime, + this.activationEndTime, + this.registrationProgress, + this.lastRegistrationResult, + }); + + final bool activated; + final int activationCode; + final bool parametersComplete; + final bool engineReady; + final int engineCode; + final int databaseCount; + final int memoryCount; + final bool registrationInProgress; + final bool libraryConsistent; + final String? activationStartTime; + final String? activationEndTime; + final FaceRegistrationProgress? registrationProgress; + final FaceRegistrationResult? lastRegistrationResult; + + bool get ready => + activated && engineReady && !registrationInProgress && libraryConsistent; + + factory FacePluginStatus.fromMap(Map map) { + return FacePluginStatus( + activated: _asBool(map['activated']), + activationCode: _asInt(map['activationCode']), + parametersComplete: _asBool(map['parametersComplete']), + engineReady: _asBool(map['engineReady']), + engineCode: _asInt(map['engineCode']), + databaseCount: _asInt(map['databaseCount']), + memoryCount: _asInt(map['memoryCount']), + registrationInProgress: _asBool(map['registrationInProgress']), + libraryConsistent: _asBool(map['libraryConsistent']), + activationStartTime: _asNullableString(map['activationStartTime']), + activationEndTime: _asNullableString(map['activationEndTime']), + registrationProgress: map['registrationProgress'] is Map + ? FaceRegistrationProgress.fromMap( + map['registrationProgress'] as Map, + ) + : null, + lastRegistrationResult: map['lastRegistrationResult'] is Map + ? FaceRegistrationResult.fromMap( + map['lastRegistrationResult'] as Map, + ) + : null, + ); + } +} + +class FaceActivationResult { + const FaceActivationResult({ + required this.success, + required this.code, + required this.message, + required this.requiresPermission, + required this.canOpenSettings, + }); + + final bool success; + final int code; + final String message; + final bool requiresPermission; + final bool canOpenSettings; + + factory FaceActivationResult.fromMap(Map map) { + return FaceActivationResult( + success: _asBool(map['success']), + code: _asInt(map['code']), + message: _asString(map['message'], '人脸插件激活失败'), + requiresPermission: _asBool(map['requiresPermission']), + canOpenSettings: _asBool(map['canOpenSettings']), + ); + } +} + +class FaceRegistrationProgress { + const FaceRegistrationProgress({ + required this.phase, + required this.message, + required this.processed, + required this.total, + required this.successCount, + required this.failedCount, + required this.inProgress, + this.currentSampleId, + }); + + final String phase; + final String message; + final int processed; + final int total; + final int successCount; + final int failedCount; + final bool inProgress; + final int? currentSampleId; + + double get fraction => + total <= 0 ? 0.0 : (processed / total).clamp(0.0, 1.0).toDouble(); + + factory FaceRegistrationProgress.fromMap(Map map) { + return FaceRegistrationProgress( + phase: _asString(map['phase'], 'idle'), + message: _asString(map['message'], '尚未执行人脸库注册'), + processed: _asInt(map['processed']), + total: _asInt(map['total']), + successCount: _asInt(map['successCount']), + failedCount: _asInt(map['failedCount']), + inProgress: _asBool(map['inProgress']), + currentSampleId: map['currentSampleId'] == null + ? null + : _asInt(map['currentSampleId']), + ); + } +} + +class FaceRegistrationResult { + const FaceRegistrationResult({ + required this.success, + required this.message, + required this.total, + required this.successCount, + required this.failedCount, + required this.failedSampleIds, + required this.durationMs, + }); + + final bool success; + final String message; + final int total; + final int successCount; + final int failedCount; + final List failedSampleIds; + final int durationMs; + + factory FaceRegistrationResult.fromMap(Map map) { + final failedSampleIds = map['failedSampleIds']; + return FaceRegistrationResult( + success: _asBool(map['success']), + message: _asString(map['message'], '人脸库注册失败'), + total: _asInt(map['total']), + successCount: _asInt(map['successCount']), + failedCount: _asInt(map['failedCount']), + failedSampleIds: failedSampleIds is List + ? failedSampleIds.map(_asInt).toList(growable: false) + : const [], + durationMs: _asInt(map['durationMs']), + ); + } +} + +bool _asBool(dynamic value) => value == true; + +int _asInt(dynamic value) => value is num ? value.toInt() : 0; + +int _asIntOr(dynamic value, int fallback) => + value is num ? value.toInt() : fallback; + +double _asDouble(dynamic value, double fallback) => + value is num ? value.toDouble() : fallback; + +bool _asBoolOr(dynamic value, bool fallback) => + value is bool ? value : fallback; + +String _asString(dynamic value, String fallback) { + final result = value?.toString(); + return result == null || result.isEmpty ? fallback : result; +} + +String? _asNullableString(dynamic value) { + final result = value?.toString(); + return result == null || result.isEmpty ? null : result; +} diff --git a/lib/common/models/plugs/face_discern_models.g.dart b/lib/common/models/plugs/face_discern_models.g.dart index e5f5880..03cbc63 100644 --- a/lib/common/models/plugs/face_discern_models.g.dart +++ b/lib/common/models/plugs/face_discern_models.g.dart @@ -13,6 +13,15 @@ FaceStream _$FaceStreamFromJson(Map json) => FaceStream( sampleId: (json['sampleId'] as num?)?.toInt(), userId: json['userId'] as String?, userName: json['userName'] as String?, + sessionId: json['sessionId'] as String?, + capturedAt: (json['capturedAt'] as num?)?.toInt(), + evidenceFilePath: json['evidenceFilePath'] as String?, + evidenceHash: json['evidenceHash'] as String?, + evidenceSize: (json['evidenceSize'] as num?)?.toInt(), + evidenceWidth: (json['evidenceWidth'] as num?)?.toInt(), + evidenceHeight: (json['evidenceHeight'] as num?)?.toInt(), + similarityThreshold: (json['similarityThreshold'] as num?)?.toDouble(), + evidenceError: json['evidenceError'] as String?, ); Map _$FaceStreamToJson(FaceStream instance) => @@ -23,6 +32,15 @@ Map _$FaceStreamToJson(FaceStream instance) => 'sampleId': instance.sampleId, 'userId': instance.userId, 'userName': instance.userName, + 'sessionId': instance.sessionId, + 'capturedAt': instance.capturedAt, + 'evidenceFilePath': instance.evidenceFilePath, + 'evidenceHash': instance.evidenceHash, + 'evidenceSize': instance.evidenceSize, + 'evidenceWidth': instance.evidenceWidth, + 'evidenceHeight': instance.evidenceHeight, + 'similarityThreshold': instance.similarityThreshold, + 'evidenceError': instance.evidenceError, }; FaceConfig _$FaceConfigFromJson(Map json) => FaceConfig( diff --git a/lib/common/models/plugs/face_recognition_session_models.dart b/lib/common/models/plugs/face_recognition_session_models.dart new file mode 100644 index 0000000..4f5a143 --- /dev/null +++ b/lib/common/models/plugs/face_recognition_session_models.dart @@ -0,0 +1,281 @@ +import 'package:flutter/material.dart'; + +/// 人脸识别会话的业务来源,只影响主副屏提示,不改变原生支付协议。 +enum FaceRecognitionScene { + /// 收银支付场景。 + cashier, + + /// 预订或核验身份场景。 + reservation, +} + +/// 原生人脸会话的生命周期状态。 +enum FaceRecognitionSessionStatus { + /// 摄像头和识别引擎尚未启动。 + cold, + + /// 正在创建摄像头或初始化识别引擎。 + initializing, + + /// 正在检测并比对人脸。 + detecting, + + /// 已获得可提交给业务层的识别结果。 + success, + + /// 会话失败,具体原因由事件错误码说明。 + failed, + + /// 会话已由用户或宿主主动取消。 + cancelled, + + /// 业务会话结束但原生摄像头和引擎仍在热复用期。 + warmIdle, +} + +/// 原生单帧上报的一张人脸框及其最近识别状态。 +class FaceRecognitionFaceBox { + /// 创建归一化坐标的人脸框。 + const FaceRecognitionFaceBox({ + required this.left, + required this.top, + required this.right, + required this.bottom, + required this.recognizeStatus, + required this.livenessStatus, + this.similarity, + }); + + /// 人脸框左边界,占原始预览宽度的比例,范围为 0 到 1。 + final double left; + + /// 人脸框上边界,占原始预览高度的比例,范围为 0 到 1。 + final double top; + + /// 人脸框右边界,占原始预览宽度的比例,范围为 0 到 1。 + final double right; + + /// 人脸框下边界,占原始预览高度的比例,范围为 0 到 1。 + final double bottom; + + /// 最近一次与本地人脸库比对的相似度,尚未比对时为空。 + final double? similarity; + + /// 原生特征提取与比对状态名称,值来自 Flutter 人脸通道协议。 + final String recognizeStatus; + + /// 原生活体检测状态名称,未启用活体或尚未检测时为 `unknown`。 + final String livenessStatus; + + /// 将归一化坐标转换为指定画布上的矩形。 + /// + /// [size] 为应用旋转与镜像前的目标画布尺寸。 + Rect toRect(Size size) => Rect.fromLTRB( + left * size.width, + top * size.height, + right * size.width, + bottom * size.height, + ); + + /// 人脸框占原始摄像头画面的比例,范围为 0 到 1。 + /// + /// 归一化面积不受画面旋转和镜像影响;异常反向坐标按 0 处理。 + double get normalizedArea { + final width = (right - left).clamp(0.0, 1.0); + final height = (bottom - top).clamp(0.0, 1.0); + return width * height; + } + + /// 将原生事件中的动态 Map 转换为边界受控的人脸框。 + factory FaceRecognitionFaceBox.fromMap(Map map) { + double value(String key) => (map[key] as num?)?.toDouble() ?? 0; + return FaceRecognitionFaceBox( + left: value('left').clamp(0, 1), + top: value('top').clamp(0, 1), + right: value('right').clamp(0, 1), + bottom: value('bottom').clamp(0, 1), + similarity: map['similarity'] is num + ? (map['similarity'] as num).toDouble().clamp(0, 1) + : null, + recognizeStatus: map['recognizeStatus']?.toString() ?? 'waiting', + livenessStatus: map['livenessStatus']?.toString() ?? 'unknown', + ); + } +} + +/// Flutter 人脸事件通道上报的完整会话快照。 +class FaceRecognitionSessionEvent { + /// 创建一帧会话状态与人脸框快照。 + const FaceRecognitionSessionEvent({ + required this.sessionId, + required this.scene, + required this.status, + required this.revision, + this.eventSequence = 0, + required this.width, + required this.height, + required this.rotation, + required this.mirror, + required this.warm, + required this.livenessEnabled, + required this.keepMaxFace, + required this.similarityThreshold, + required this.message, + required this.faceBoxes, + this.errorCode, + }); + + /// 当前业务会话唯一编号,用于过滤上一会话迟到事件。 + final String sessionId; + + /// 发起识别的业务场景。 + final FaceRecognitionScene scene; + + /// 原生会话当前生命周期状态。 + final FaceRecognitionSessionStatus status; + + /// 单会话内递增的事件版本,用于忽略乱序或重复事件。 + final int revision; + + /// 原生进程内单调递增的事件序号,用于过滤跨业务会话和跨线程迟到的状态。 + /// + /// 旧版原生插件未提供该字段时为 0,并回退到会话编号与 [revision] 兼容判断。 + final int eventSequence; + + /// 原始摄像头预览宽度,单位为像素。 + final int width; + + /// 原始摄像头预览高度,单位为像素。 + final int height; + + /// 纹理显示旋转角度,单位为度,通常为 0、90、180 或 270。 + final int rotation; + + /// 是否需要水平镜像前置摄像头画面和人脸框。 + final bool mirror; + + /// 原生摄像头与引擎是否已进入可复用热状态。 + final bool warm; + + /// 当前原生配置是否启用 RGB 活体检测。 + final bool livenessEnabled; + + /// 当前原生配置是否只允许面积最大的人脸参与识别。 + final bool keepMaxFace; + + /// 当前人脸比对成功阈值,范围为 0 到 1。 + final double similarityThreshold; + + /// 面向用户的当前状态或错误说明。 + final String message; + + /// 当前帧检测到的全部人脸框;最大脸模式下也保留非目标人脸。 + final List faceBoxes; + + /// 原生结构化错误码;没有错误时为空。 + final String? errorCode; + + /// 会话是否仍处于初始化或实时检测阶段。 + bool get isActive => + status == FaceRecognitionSessionStatus.initializing || + status == FaceRecognitionSessionStatus.detecting; + + /// 将原生事件通道 Map 转换为强类型会话快照。 + factory FaceRecognitionSessionEvent.fromMap(Map map) { + final rawBoxes = map['faceBoxes']; + return FaceRecognitionSessionEvent( + sessionId: map['sessionId']?.toString() ?? '', + scene: FaceRecognitionScene.values.firstWhere( + (value) => value.name == map['scene'], + orElse: () => FaceRecognitionScene.cashier, + ), + status: FaceRecognitionSessionStatus.values.firstWhere( + (value) => value.name == map['state'], + orElse: () => FaceRecognitionSessionStatus.cold, + ), + revision: (map['revision'] as num?)?.toInt() ?? 0, + eventSequence: (map['eventSequence'] as num?)?.toInt() ?? 0, + width: (map['width'] as num?)?.toInt() ?? 1280, + height: (map['height'] as num?)?.toInt() ?? 720, + rotation: (map['rotation'] as num?)?.toInt() ?? 0, + mirror: map['mirror'] == true, + warm: map['warm'] == true, + livenessEnabled: map['livenessEnabled'] == true, + keepMaxFace: map['keepMaxFace'] == true, + similarityThreshold: + (map['similarityThreshold'] as num?)?.toDouble() ?? 0.8, + message: map['message']?.toString() ?? '', + faceBoxes: rawBoxes is List + ? rawBoxes + .whereType() + .map(FaceRecognitionFaceBox.fromMap) + .toList(growable: false) + : const [], + errorCode: map['errorCode']?.toString(), + ); + } +} + +/// 原生 Camera1 共享输出在当前 Flutter Engine 中的 Texture 元数据。 +class FaceRecognitionPreviewTexture { + /// 创建预览纹理元数据。 + const FaceRecognitionPreviewTexture({ + required this.textureId, + required this.width, + required this.height, + required this.rotation, + required this.mirror, + }); + + /// 当前 Flutter Engine 可渲染的 Texture 编号。 + final int textureId; + + /// 原始预览宽度,单位为像素。 + final int width; + + /// 原始预览高度,单位为像素。 + final int height; + + /// Flutter 展示纹理时需要应用的旋转角度,单位为度。 + final int rotation; + + /// Flutter 展示纹理时是否需要水平镜像。 + final bool mirror; + + /// 将原生纹理创建结果转换为强类型元数据。 + factory FaceRecognitionPreviewTexture.fromMap(Map map) { + return FaceRecognitionPreviewTexture( + textureId: (map['textureId'] as num).toInt(), + width: (map['width'] as num?)?.toInt() ?? 1280, + height: (map['height'] as num?)?.toInt() ?? 720, + rotation: (map['rotation'] as num?)?.toInt() ?? 0, + mirror: map['mirror'] == true, + ); + } +} + +/// 人脸识别会话可供业务层判断恢复策略的结构化异常。 +class FaceRecognitionFailure implements Exception { + /// 创建带稳定错误码和用户说明的识别异常。 + const FaceRecognitionFailure({required this.code, required this.message}); + + /// Flutter 与原生会话约定的稳定错误码。 + final String code; + + /// 可直接展示给收银员的失败原因与恢复提示。 + final String message; + + /// 异常是否表示用户或宿主主动取消。 + bool get cancelled => code == 'CANCELLED'; + + /// 异常是否要求销毁当前业务会话后重新发起识别。 + bool get terminatesSession => + cancelled || + code == 'LOW_MEMORY' || + code == 'SESSION_SUSPENDED' || + code == 'SESSION_STREAM_ERROR'; + + /// 返回日志和默认错误展示使用的中文说明。 + @override + String toString() => message; +} diff --git a/lib/common/models/plugs/face_startup_preparation_models.dart b/lib/common/models/plugs/face_startup_preparation_models.dart new file mode 100644 index 0000000..bf67c99 --- /dev/null +++ b/lib/common/models/plugs/face_startup_preparation_models.dart @@ -0,0 +1,53 @@ +/// 启动阶段准备人脸能力的最终状态。 +enum FaceStartupPreparationStatus { + /// 参数、激活和本地人脸库注册均已完成。 + success, + + /// 当前运行环境明确不使用真实人脸硬件,因此主动跳过。 + skipped, + + /// 人脸能力暂不可用或部分样本失败,但不阻断收银应用启动。 + warning, +} + +/// Load 页消费的人脸准备结果,避免把插件异常升级为整个应用启动失败。 +class FaceStartupPreparationResult { + /// 创建一份包含激活结果、注册统计和可排查信息的不可变结果。 + const FaceStartupPreparationResult({ + required this.status, + required this.message, + required this.total, + required this.successCount, + required this.failedCount, + required this.failedSampleIds, + required this.missingParameters, + this.activationCode, + }); + + /// 启动步骤的最终状态,用于映射 Load 页成功或警告样式。 + final FaceStartupPreparationStatus status; + + /// 面向终端管理员展示的结果摘要,不包含任何激活密钥明文。 + final String message; + + /// 本地数据库参与本次整库注册的人脸样本总数。 + final int total; + + /// 成功装载到虹软内存人脸库的样本数量。 + final int successCount; + + /// 未能装载到内存库的样本数量。 + final int failedCount; + + /// 注册失败的本地样本 ID,只用于设置页定位异常记录。 + final List failedSampleIds; + + /// 缺失的设备人脸参数名称,仅包含 APP_ID 等字段名,不包含值。 + final List missingParameters; + + /// 虹软激活返回码;未调用激活接口时为空。 + final int? activationCode; + + /// 当前结果是否需要在 Load 页以受限启动警告展示。 + bool get hasWarning => status != FaceStartupPreparationStatus.success; +} diff --git a/lib/common/models/plugs/serial_card_models.dart b/lib/common/models/plugs/serial_card_models.dart index e69de29..8763870 100644 --- a/lib/common/models/plugs/serial_card_models.dart +++ b/lib/common/models/plugs/serial_card_models.dart @@ -0,0 +1,195 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'serial_card_models.g.dart'; + +const String _serialCardDefaultKeyHex = 'FFFFFFFFFFFF'; + +/// DK25-ST 可以识别的卡片类型。 +enum SerialCardType { + undefined(0x00, '未定义', null), + mifare(0x01, 'Mifare(M1)', 4), + ultralight(0x02, 'Ultralight / NFC 标签', 7), + iso14443B(0x03, 'ISO14443-B', 8), + iso14443ACpu(0x04, 'ISO14443-A CPU 卡', 4), + iso15693(0x05, 'ISO15693', 8); + + const SerialCardType(this.code, this.title, this.uidLength); + + final int code; + final String title; + final int? uidLength; + + static SerialCardType fromCode(int code) { + return values.where((type) => type.code == code).firstOrNull ?? undefined; + } +} + +/// 业务卡号读取策略,旧饭卡默认保留 M1 数据块优先的行为。 +@JsonEnum(valueField: 'code') +enum SerialCardReadMode { + smart('smart', '智能识别'), + uidOnly('uid_only', '仅读取 UID'), + mifareBlockOnly('mifare_block_only', '仅 M1 业务块'); + + const SerialCardReadMode(this.code, this.title); + + final String code; + final String title; +} + +/// M1 卡扇区认证使用的密钥类型。 +@JsonEnum(valueField: 'code') +enum SerialMifareKeyType { + keyA('key_a', 'KEY A', 0x0A), + keyB('key_b', 'KEY B', 0x0B); + + const SerialMifareKeyType(this.code, this.title, this.protocolValue); + + final String code; + final String title; + final int protocolValue; +} + +/// 读写卡配置保存在已有 setting.sysJson 扩展键中,不改变数据库结构。 +@JsonSerializable() +class SerialCardConfig { + static const String defaultKeyHex = _serialCardDefaultKeyHex; + + const SerialCardConfig({ + this.readMode = SerialCardReadMode.smart, + this.mifareBlockAddress = 1, + this.mifareKeyType = SerialMifareKeyType.keyA, + this.keyAHex = _serialCardDefaultKeyHex, + this.keyBHex = _serialCardDefaultKeyHex, + this.showMifareScanButton = false, + }); + + @JsonKey(unknownEnumValue: SerialCardReadMode.smart) + final SerialCardReadMode readMode; + + @JsonKey(fromJson: _serialCardBlockAddressFromJson) + final int mifareBlockAddress; + + @JsonKey(unknownEnumValue: SerialMifareKeyType.keyA) + final SerialMifareKeyType mifareKeyType; + + @JsonKey( + fromJson: _serialCardKeyFromJson, + toJson: _serialCardKeyToJson, + ) + final String keyAHex; + + @JsonKey( + fromJson: _serialCardKeyFromJson, + toJson: _serialCardKeyToJson, + ) + final String keyBHex; + + /// M1 全块扫描属于现场诊断能力,默认隐藏,避免普通业务读卡误触耗时扫描。 + final bool showMifareScanButton; + + String get selectedKeyHex => + mifareKeyType == SerialMifareKeyType.keyA ? keyAHex : keyBHex; + + SerialCardConfig copyWith({ + SerialCardReadMode? readMode, + int? mifareBlockAddress, + SerialMifareKeyType? mifareKeyType, + String? keyAHex, + String? keyBHex, + bool? showMifareScanButton, + }) { + return SerialCardConfig( + readMode: readMode ?? this.readMode, + mifareBlockAddress: mifareBlockAddress ?? this.mifareBlockAddress, + mifareKeyType: mifareKeyType ?? this.mifareKeyType, + keyAHex: keyAHex ?? this.keyAHex, + keyBHex: keyBHex ?? this.keyBHex, + showMifareScanButton: showMifareScanButton ?? this.showMifareScanButton, + ); + } + + factory SerialCardConfig.fromJson(Map json) => + _$SerialCardConfigFromJson(json); + + Map toJson() => _$SerialCardConfigToJson(this); +} + +/// M1 数据块扫描中发现的非空内容。 +/// +/// 扫描严格跳过厂商块和扇区密钥块,因此这里只包含允许读取的普通数据块。 +class SerialMifareBlockScanItem { + const SerialMifareBlockScanItem({ + required this.blockAddress, + required this.hex, + this.text, + }); + + final int blockAddress; + final String hex; + final String? text; + + /// 单个 M1 数据块最多保存 16 字节文本;不再限定历史卡号必须为 12 位。 + /// + /// 该判断只用于界面标记候选位置,最终仍需结合实际发卡规则确认。 + bool get isCardNumberCandidate { + final value = text; + return value != null && RegExp(r'^[a-zA-Z0-9]{1,16}$').hasMatch(value); + } +} + +/// M1 安全数据块只读扫描进度。 +class SerialMifareScanProgress { + const SerialMifareScanProgress({ + required this.processedBlocks, + required this.totalBlocks, + required this.readableBlocks, + required this.failedBlocks, + required this.items, + this.currentBlockAddress, + this.completed = false, + this.detectedS50 = false, + this.stopReason, + }); + + const SerialMifareScanProgress.initial({required this.totalBlocks}) + : processedBlocks = 0, + readableBlocks = 0, + failedBlocks = 0, + items = const [], + currentBlockAddress = null, + completed = false, + detectedS50 = false, + stopReason = null; + + final int processedBlocks; + final int totalBlocks; + final int readableBlocks; + final int failedBlocks; + final List items; + final int? currentBlockAddress; + final bool completed; + + /// 旧版扫描容量推断标记;当前完整扫描 215 个安全块时固定为 false,仅保留结果模型兼容性。 + final bool detectedS50; + + /// 非正常完整遍历时的结束原因;已读取到的块结果仍然保留并展示。 + final String? stopReason; + + double get fraction => totalBlocks <= 0 + ? 0 + : (processedBlocks / totalBlocks).clamp(0, 1).toDouble(); +} + +int _serialCardBlockAddressFromJson(Object? value) { + return value is int && value >= 0 && value <= 255 ? value : 1; +} + +String _serialCardKeyFromJson(Object? value) { + final key = value?.toString().replaceAll(RegExp(r'\s+'), '').toUpperCase(); + return key != null && RegExp(r'^[0-9A-F]{12}$').hasMatch(key) + ? key + : SerialCardConfig.defaultKeyHex; +} + +String _serialCardKeyToJson(String value) => _serialCardKeyFromJson(value); diff --git a/lib/common/models/plugs/serial_card_models.g.dart b/lib/common/models/plugs/serial_card_models.g.dart new file mode 100644 index 0000000..391c60f --- /dev/null +++ b/lib/common/models/plugs/serial_card_models.g.dart @@ -0,0 +1,50 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'serial_card_models.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SerialCardConfig _$SerialCardConfigFromJson(Map json) => + SerialCardConfig( + readMode: $enumDecodeNullable( + _$SerialCardReadModeEnumMap, json['readMode'], + unknownValue: SerialCardReadMode.smart) ?? + SerialCardReadMode.smart, + mifareBlockAddress: json['mifareBlockAddress'] == null + ? 1 + : _serialCardBlockAddressFromJson(json['mifareBlockAddress']), + mifareKeyType: $enumDecodeNullable( + _$SerialMifareKeyTypeEnumMap, json['mifareKeyType'], + unknownValue: SerialMifareKeyType.keyA) ?? + SerialMifareKeyType.keyA, + keyAHex: json['keyAHex'] == null + ? _serialCardDefaultKeyHex + : _serialCardKeyFromJson(json['keyAHex']), + keyBHex: json['keyBHex'] == null + ? _serialCardDefaultKeyHex + : _serialCardKeyFromJson(json['keyBHex']), + showMifareScanButton: json['showMifareScanButton'] as bool? ?? false, + ); + +Map _$SerialCardConfigToJson(SerialCardConfig instance) => + { + 'readMode': _$SerialCardReadModeEnumMap[instance.readMode]!, + 'mifareBlockAddress': instance.mifareBlockAddress, + 'mifareKeyType': _$SerialMifareKeyTypeEnumMap[instance.mifareKeyType]!, + 'keyAHex': _serialCardKeyToJson(instance.keyAHex), + 'keyBHex': _serialCardKeyToJson(instance.keyBHex), + 'showMifareScanButton': instance.showMifareScanButton, + }; + +const _$SerialCardReadModeEnumMap = { + SerialCardReadMode.smart: 'smart', + SerialCardReadMode.uidOnly: 'uid_only', + SerialCardReadMode.mifareBlockOnly: 'mifare_block_only', +}; + +const _$SerialMifareKeyTypeEnumMap = { + SerialMifareKeyType.keyA: 'key_a', + SerialMifareKeyType.keyB: 'key_b', +}; diff --git a/lib/common/models/plugs/sub_screen_enum_models.dart b/lib/common/models/plugs/sub_screen_enum_models.dart index d8f9353..7d9d81f 100644 --- a/lib/common/models/plugs/sub_screen_enum_models.dart +++ b/lib/common/models/plugs/sub_screen_enum_models.dart @@ -2,18 +2,67 @@ import 'package:pj_supermarket_cloud/common/index.dart'; enum SubScreenAction implements BaseEnum { + /// 同步当前购物车和金额快照。 updateCard("updateCard", "订单更新"), + + /// 跳转到结算副屏页面。 jumpSubPage("jumpSubPage", "跳转到订单选择页面"), + + /// 返回副屏上一页面。 jumpSubBack("jumpSubBack", "跳转到上一个页面"), + + /// 展示旧版自有钱包订单二维码。 qrCodeScan("qrCodeScan", "二维码扫一扫"), + + /// 旧版自有钱包订单二维码支付成功。 qrCodeScanSuccess("qrCodeScanSuccess", "二维码支付成功"), + + /// 旧版自有钱包订单二维码支付取消。 qrCodeScanCancel("qrCodeScanCancel", "二维码支付取消"), + + /// 展示支付宝、微信共用的聚合订单二维码。 + aggregateOrderPay("aggregateOrderPay", "聚合订单码支付"), + + /// 聚合订单码支付成功。 + aggregateOrderPaySuccess("aggregateOrderPaySuccess", "聚合订单码支付成功"), + + /// 聚合订单码支付取消、关闭或过期。 + aggregateOrderPayCancel("aggregateOrderPayCancel", "聚合订单码支付取消"), + + /// 更新预约查询副屏状态。 + reservationQueryUpdate("reservationQueryUpdate", "预约查询状态更新"), + + /// 清空预约查询副屏状态。 + reservationQueryClear("reservationQueryClear", "预约查询清屏"), + + /// 开始人脸识别副屏会话。 + faceRecognitionStart("faceRecognitionStart", "人脸识别开始"), + + /// 展示人脸检测中状态。 + faceRecognitionDetecting("faceRecognitionDetecting", "人脸识别中"), + + /// 展示人脸识别成功状态。 + faceRecognitionSuccess("faceRecognitionSuccess", "人脸识别成功"), + + /// 展示人脸识别失败状态。 + faceRecognitionFailed("faceRecognitionFailed", "人脸识别失败"), + + /// 取消人脸识别副屏会话。 + faceRecognitionCancel("faceRecognitionCancel", "人脸识别取消"), + + /// 通知副屏当前订单已经完成支付,适用于饭卡、钱包、人脸和付款码等非聚合码支付。 + orderPaySuccess("orderPaySuccess", "订单支付成功"), ; @override + + /// 主副屏协议使用的稳定动作编码。 final String code; @override + + /// 动作的中文业务说明。 final String title; + /// 创建一个主副屏协议动作枚举值。 const SubScreenAction(this.code, this.title); } diff --git a/lib/common/routers/names.dart b/lib/common/routers/names.dart index 67cddb6..586f1bd 100644 --- a/lib/common/routers/names.dart +++ b/lib/common/routers/names.dart @@ -32,6 +32,9 @@ class RouteNames { /// 设置 static const setting = '/setting'; + /// 定时任务日志 + static const settingSyncLogs = '/setting/sync-logs'; + /// 饭卡 static const cardActive = '/card_active'; diff --git a/lib/common/routers/pages.dart b/lib/common/routers/pages.dart index 3cec8aa..95db03a 100644 --- a/lib/common/routers/pages.dart +++ b/lib/common/routers/pages.dart @@ -89,6 +89,16 @@ class RoutePages { Get.lazyPut(() => SettingController()); }), ), + GetPage( + name: RouteNames.settingSyncLogs, + page: () => const ScheduledTaskLogsPage(), + binding: BindingsBuilder(() { + Get.lazyPut( + () => ScheduledTaskLogsController(Get.arguments as ScheduledTaskType), + fenix: true, + ); + }), + ), GetPage( name: RouteNames.cardActive, page: () => const CardActivePage(), diff --git a/lib/common/services/database/dao/face_dao.dart b/lib/common/services/database/dao/face_dao.dart index c0ddae6..dfc16d1 100644 --- a/lib/common/services/database/dao/face_dao.dart +++ b/lib/common/services/database/dao/face_dao.dart @@ -5,6 +5,7 @@ import 'package:pj_supermarket_cloud/common/index.dart'; // face @dao abstract class FaceDao { + /// 保存一条已完成文件和原生特征校验的人脸记录。 @insert Future save(FaceEntity entity); @@ -48,6 +49,59 @@ abstract class FaceDao { """) Future findCount(String userName); + /// 统计已完成质量检测且低于指定阈值的人脸数量,阈值不写入样本以支持即时重分类。 + @Query(''' + SELECT COUNT(0) FROM face + WHERE qualityStatus = 'detected' + AND qualityScore IS NOT NULL + AND qualityScore < :threshold + AND (:userName = '-1' OR userName LIKE '%' || :userName || '%') + ''') + Future findLowQualityCount(String userName, double threshold); + + /// 分页查询已检测且低于阈值的人脸,结果按本地自增编号稳定排序。 + @Query(''' + SELECT * FROM face + WHERE qualityStatus = 'detected' + AND qualityScore IS NOT NULL + AND qualityScore < :threshold + AND (:userName = '-1' OR userName LIKE '%' || :userName || '%') + ORDER BY id ASC + LIMIT :pageSize OFFSET :offset + ''') + Future> findLowQuality( + String userName, + double threshold, + int pageSize, + int offset, + ); + + /// 查询全部已检测且低于阈值的人脸,用于一键删除前获取完整服务端删除集合。 + @Query(''' + SELECT * FROM face + WHERE qualityStatus = 'detected' + AND qualityScore IS NOT NULL + AND qualityScore < :threshold + ORDER BY id ASC + ''') + Future> findAllLowQuality(double threshold); + + /// 统计尚未完成质量检测的历史样本数量。 + @Query("SELECT COUNT(0) FROM face WHERE qualityStatus = 'pending'") + Future findPendingQualityCount(); + + /// 统计最近一次质量检测失败的样本数量。 + @Query("SELECT COUNT(0) FROM face WHERE qualityStatus = 'failed'") + Future findFailedQualityCount(); + + /// 查询待检测或失败后重试的本地样本,按编号稳定排序。 + @Query(""" + SELECT * FROM face + WHERE qualityStatus IN ('pending', 'failed') + ORDER BY id ASC + """) + Future> findQualityAuditCandidates(); + @Query(''' SELECT * FROM face WHERE id = :id ''') @@ -75,6 +129,12 @@ abstract class FaceDao { int offset, ); + /// 查询本机全部人脸,用于完整部门快照计算集合差异。 + @Query(''' + SELECT * FROM face ORDER BY sampleId ASC + ''') + Future> getAll(); + @Query(''' SELECT DISTINCT a.deptId FROM face a WHERE a.deptId IS NOT NULL ''') diff --git a/lib/common/services/database/dao/file_upload_record_dao.dart b/lib/common/services/database/dao/file_upload_record_dao.dart new file mode 100644 index 0000000..509646b --- /dev/null +++ b/lib/common/services/database/dao/file_upload_record_dao.dart @@ -0,0 +1,40 @@ +import 'package:floor/floor.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// `file_upload_record` 本机文件上传状态的数据访问接口。 +@dao +abstract class FileUploadRecordDao { + /// 保存文件状态并返回主键;相同主键时替换原记录以保留同一路径的最新状态。 + @Insert(onConflict: OnConflictStrategy.replace) + Future save(FileUploadRecordEntity entity); + + /// 按主键更新已经存在的文件上传状态。 + @update + Future edit(FileUploadRecordEntity entity); + + /// 查询指定设备与业务类型的全部文件状态,并按最近更新时间倒序返回。 + @Query(''' + SELECT * FROM file_upload_record + WHERE hardId = :hardId AND bizType = :bizType + ORDER BY updateTime DESC + ''') + Future> getAll( + String hardId, + String bizType, + ); + + /// 按设备、业务类型和相对路径查询唯一状态记录;不存在时返回空。 + @Query(''' + SELECT * FROM file_upload_record + WHERE hardId = :hardId + AND bizType = :bizType + AND relativePath = :relativePath + LIMIT 1 + ''') + Future getByPath( + String hardId, + String bizType, + String relativePath, + ); +} diff --git a/lib/common/services/database/dao/order_evidence_upload_task_dao.dart b/lib/common/services/database/dao/order_evidence_upload_task_dao.dart new file mode 100644 index 0000000..219ec2a --- /dev/null +++ b/lib/common/services/database/dao/order_evidence_upload_task_dao.dart @@ -0,0 +1,131 @@ +import 'package:floor/floor.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// `order_evidence_upload_task` 本地补传队列的数据访问接口。 +@dao +abstract class OrderEvidenceUploadTaskDao { + /// 保存任务并返回主键;同一主键冲突时以最新状态整体替换。 + @Insert(onConflict: OnConflictStrategy.replace) + Future save(OrderEvidenceUploadTaskEntity entity); + + /// 按主键更新已经存在的任务,不改变证据位置唯一键。 + @update + Future edit(OrderEvidenceUploadTaskEntity entity); + + /// 按订单号和证据位置查询唯一任务;不存在时返回空。 + @Query(''' + SELECT * FROM order_evidence_upload_task + WHERE androidOrderCode = :androidOrderCode + AND evidenceType = :evidenceType + AND evidenceRole = :evidenceRole + AND sequenceNo = :sequenceNo + LIMIT 1 + ''') + Future getByPosition( + String androidOrderCode, + String evidenceType, + String evidenceRole, + int sequenceNo, + ); + + /// 按服务端订单 ID 和证据位置查询预约核销任务;不存在时返回空。 + @Query(''' + SELECT * FROM order_evidence_upload_task + WHERE serverOrderId = :serverOrderId + AND evidenceType = :evidenceType + AND evidenceRole = :evidenceRole + AND sequenceNo = :sequenceNo + LIMIT 1 + ''') + Future getByServerOrderPosition( + String serverOrderId, + String evidenceType, + String evidenceRole, + int sequenceNo, + ); + + /// 查询同一终端订单的全部任务,按类型、角色和序号稳定排序。 + @Query(''' + SELECT * FROM order_evidence_upload_task + WHERE androidOrderCode = :androidOrderCode + ORDER BY evidenceType, evidenceRole, sequenceNo + ''') + Future> getByOrderCode( + String androidOrderCode, + ); + + /// 查询同一识别会话创建的参考图与现场图任务,用于支付前绑定订单或取消清理。 + @Query(''' + SELECT * FROM order_evidence_upload_task + WHERE recognitionSessionId = :recognitionSessionId + ORDER BY evidenceRole, sequenceNo + ''') + Future> getByRecognitionSession( + String recognitionSessionId, + ); + + /// 查询到达重试时间的活动任务,每轮最多由调用方传入 20 条并按创建时间串行处理。 + @Query(''' + SELECT * FROM order_evidence_upload_task + WHERE status IN ('WAITING_ORDER', 'READY', 'UPLOADING', 'RETRY_WAIT') + AND (androidOrderCode IS NOT NULL OR serverOrderId IS NOT NULL) + AND (nextRetryAt IS NULL OR nextRetryAt <= :now) + ORDER BY createTime ASC, id ASC + LIMIT :limit + ''') + Future> getDueTasks( + DateTime now, + int limit, + ); + + /// 查询需要向服务端报告永久缺失的已绑定终态任务,已报告记录不会重复返回。 + @Query(''' + SELECT * FROM order_evidence_upload_task + WHERE status IN ('PERMANENT_FAILED', 'EXPIRED') + AND (androidOrderCode IS NOT NULL OR serverOrderId IS NOT NULL) + AND unavailableReportedAt IS NULL + AND (nextRetryAt IS NULL OR nextRetryAt <= :now) + ORDER BY updateTime ASC, id ASC + LIMIT :limit + ''') + Future> getUnavailableReportTasks( + DateTime now, + int limit, + ); + + /// 查询已超过 24 小时绑定期限的等待订单任务,调用方删除图片后转为 EXPIRED。 + @Query(''' + SELECT * FROM order_evidence_upload_task + WHERE status = 'WAITING_ORDER' AND expiresAt <= :now + ORDER BY expiresAt ASC + ''') + Future> getExpiredWaitingTasks( + DateTime now, + ); + + /// 查询已超过 30 天图片保留期且仍持有现场文件的非成功任务。 + @Query(''' + SELECT * FROM order_evidence_upload_task + WHERE localFilePath IS NOT NULL + AND status NOT IN ('SUCCESS', 'CANCELLED') + AND expiresAt <= :now + ORDER BY expiresAt ASC + ''') + Future> getExpiredImageTasks( + DateTime now, + ); + + /// 删除完成时间早于指定时间的成功任务元数据,其他失败终态不自动删除。 + @Query(''' + DELETE FROM order_evidence_upload_task + WHERE status = 'SUCCESS' + AND completedAt IS NOT NULL + AND completedAt < :before + ''') + Future deleteSuccessfulBefore(DateTime before); + + /// 查询全部任务,仅用于迁移验证和针对性测试,不参与生产批量处理。 + @Query('SELECT * FROM order_evidence_upload_task ORDER BY id ASC') + Future> getAll(); +} diff --git a/lib/common/services/database/dao/print_history_dao.dart b/lib/common/services/database/dao/print_history_dao.dart index 3cbfe6b..d1952c4 100644 --- a/lib/common/services/database/dao/print_history_dao.dart +++ b/lib/common/services/database/dao/print_history_dao.dart @@ -31,4 +31,24 @@ abstract class PrintHistoryDao { (:status = '-1' OR status = :status) ''') Future> getAll(String status); + + /// 以服务端任务 ID 唯一定位本地记录,作为 MQTT/HTTP 双通道去重依据。 + @Query( + 'SELECT * FROM print_history WHERE servicePrintId = :servicePrintId LIMIT 1') + Future findByServicePrintId(String servicePrintId); + + /// 查询等待补报成功或失败结果的任务。 + @Query( + 'SELECT * FROM print_history WHERE resultPending = 1 ORDER BY createTime ASC') + Future> getPendingResults(); + + @Query("SELECT COUNT(*) FROM print_history WHERE status = 'NONE'") + Future countPending(); + + @Query("SELECT COUNT(*) FROM print_history WHERE status = 'ERROR'") + Future countFailed(); + + @Query( + "SELECT * FROM print_history WHERE lastError IS NOT NULL AND lastError != '' ORDER BY id DESC LIMIT 1") + Future findLatestError(); } diff --git a/lib/common/services/database/dao/scheduled_task_dao.dart b/lib/common/services/database/dao/scheduled_task_dao.dart new file mode 100644 index 0000000..5a7d8e3 --- /dev/null +++ b/lib/common/services/database/dao/scheduled_task_dao.dart @@ -0,0 +1,27 @@ +import 'package:floor/floor.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// `scheduled_task` 配置和最近执行快照的数据访问接口。 +@dao +abstract class ScheduledTaskDao { + /// 按 taskCode 保存单条任务;主键冲突时以新配置和执行快照整体替换旧记录。 + @Insert(onConflict: OnConflictStrategy.replace) + Future save(ScheduledTaskEntity entity); + + /// 批量补齐默认任务;每条记录均按 taskCode 使用替换冲突策略。 + @Insert(onConflict: OnConflictStrategy.replace) + Future batchSave(List entities); + + /// 按实体主键更新一条已经存在的任务记录。 + @update + Future edit(ScheduledTaskEntity entity); + + /// 查询全部任务并按稳定 taskCode 升序返回,便于启动时构建确定性配置映射。 + @Query('SELECT * FROM scheduled_task ORDER BY taskCode') + Future> getAll(); + + /// 按稳定 taskCode 查询单条任务;不存在时返回空。 + @Query('SELECT * FROM scheduled_task WHERE taskCode = :taskCode LIMIT 1') + Future getByCode(String taskCode); +} diff --git a/lib/common/services/database/dao/scheduled_task_log_dao.dart b/lib/common/services/database/dao/scheduled_task_log_dao.dart new file mode 100644 index 0000000..06635e2 --- /dev/null +++ b/lib/common/services/database/dao/scheduled_task_log_dao.dart @@ -0,0 +1,70 @@ +import 'package:floor/floor.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// `scheduled_task_log` 结构化执行日志的数据访问接口。 +@dao +abstract class ScheduledTaskLogDao { + /// 新增任务开始日志并返回 SQLite 自增主键。 + @insert + Future save(ScheduledTaskLogEntity entity); + + /// 按日志主键写回结束状态、耗时、错误和统计字段。 + @update + Future edit(ScheduledTaskLogEntity entity); + + /// 按任务编码查询最近日志,可选依赖类型过滤,并按主键倒序限制返回数量。 + @Query(''' + SELECT * FROM scheduled_task_log + WHERE taskCode = :taskCode + AND (:sourceType = '' OR sourceType = :sourceType) + ORDER BY id DESC + LIMIT :limit + ''') + Future> getByTask( + String taskCode, + String sourceType, + int limit, + ); + + /// 按任务编码和可选来源分页查询日志,使用主键倒序保证翻页期间最新记录始终位于前页。 + @Query(''' + SELECT * FROM scheduled_task_log + WHERE taskCode = :taskCode + AND (:sourceType = '' OR sourceType = :sourceType) + ORDER BY id DESC + LIMIT :pageSize OFFSET :offset + ''') + Future> getPageByTask( + String taskCode, + String sourceType, + int pageSize, + int offset, + ); + + /// 统计指定任务和可选来源下的日志总数,为分页组件提供准确的总记录数。 + @Query(''' + SELECT COUNT(0) FROM scheduled_task_log + WHERE taskCode = :taskCode + AND (:sourceType = '' OR sourceType = :sourceType) + ''') + Future countByTask(String taskCode, String sourceType); + + /// 查询指定任务最近一条日志;从未执行时返回空。 + @Query(''' + SELECT * FROM scheduled_task_log + WHERE taskCode = :taskCode + ORDER BY id DESC + LIMIT 1 + ''') + Future getLatest(String taskCode); + + /// 终端长期常驻,执行日志只保留最近 2000 条,避免数据库无限增长。 + @Query(''' + DELETE FROM scheduled_task_log + WHERE id NOT IN ( + SELECT id FROM scheduled_task_log ORDER BY id DESC LIMIT 2000 + ) + ''') + Future trimHistory(); +} diff --git a/lib/common/services/database/database.dart b/lib/common/services/database/database.dart index 145d478..1de26c7 100644 --- a/lib/common/services/database/database.dart +++ b/lib/common/services/database/database.dart @@ -22,6 +22,10 @@ const entities = [ UserEntity, FaceEntity, SettingEntity, + ScheduledTaskEntity, + ScheduledTaskLogEntity, + FileUploadRecordEntity, + OrderEvidenceUploadTaskEntity, ]; const typeConverters = [ @@ -40,7 +44,7 @@ const typeConverters = [ BoolConverter, ]; -@Database(version: 9, entities: entities) +@Database(version: 10, entities: entities) @TypeConverters(typeConverters) abstract class AppDatabase extends FloorDatabase { BroadcastDao get getBroadcastDao; @@ -56,6 +60,10 @@ abstract class AppDatabase extends FloorDatabase { UserDao get getUserDao; FaceDao get getFaceDao; SettingDao get getSettingDao; + ScheduledTaskDao get getScheduledTaskDao; + ScheduledTaskLogDao get getScheduledTaskLogDao; + FileUploadRecordDao get getFileUploadRecordDao; + OrderEvidenceUploadTaskDao get getOrderEvidenceUploadTaskDao; static Future getDataBase() async { final dbPath = await AppFileTool.getDatabasePath(); diff --git a/lib/common/services/database/database.g.dart b/lib/common/services/database/database.g.dart index 8a3c08c..3715c86 100644 --- a/lib/common/services/database/database.g.dart +++ b/lib/common/services/database/database.g.dart @@ -98,13 +98,21 @@ class _$AppDatabase extends AppDatabase { SettingDao? _getSettingDaoInstance; + ScheduledTaskDao? _getScheduledTaskDaoInstance; + + ScheduledTaskLogDao? _getScheduledTaskLogDaoInstance; + + FileUploadRecordDao? _getFileUploadRecordDaoInstance; + + OrderEvidenceUploadTaskDao? _getOrderEvidenceUploadTaskDaoInstance; + Future open( String path, List migrations, [ Callback? callback, ]) async { final databaseOptions = sqflite.OpenDatabaseOptions( - version: 9, + version: 10, onConfigure: (database) async { await database.execute('PRAGMA foreign_keys = ON'); await callback?.onConfigure?.call(database); @@ -138,13 +146,35 @@ class _$AppDatabase extends AppDatabase { await database.execute( 'CREATE TABLE IF NOT EXISTS `classify` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `serviceUniqueCode` TEXT NOT NULL, `sysType` TEXT NOT NULL, `name` TEXT NOT NULL, `categoryType` TEXT NOT NULL, `sort` INTEGER NOT NULL, `createTime` TEXT NOT NULL, `updateTime` TEXT NOT NULL, `delFlag` INTEGER NOT NULL)'); await database.execute( - 'CREATE TABLE IF NOT EXISTS `print_history` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `orderId` TEXT NOT NULL, `serviceOrderId` TEXT NOT NULL, `servicePrintId` TEXT NOT NULL, `invoiceUrl` TEXT NOT NULL, `status` TEXT NOT NULL, `printTime` TEXT, `syncTime` TEXT, `createTime` TEXT NOT NULL)'); + 'CREATE TABLE IF NOT EXISTS `print_history` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `orderId` TEXT NOT NULL, `serviceOrderId` TEXT NOT NULL, `servicePrintId` TEXT NOT NULL, `invoiceUrl` TEXT NOT NULL, `printType` TEXT NOT NULL, `printNo` INTEGER NOT NULL, `retryVersion` INTEGER NOT NULL, `attemptCount` INTEGER NOT NULL, `lastError` TEXT, `resultPending` INTEGER NOT NULL, `status` TEXT NOT NULL, `printTime` TEXT, `syncTime` TEXT, `createTime` TEXT NOT NULL)'); await database.execute( 'CREATE TABLE IF NOT EXISTS `user_record` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userId` TEXT NOT NULL, `nickname` TEXT NOT NULL, `mobile` TEXT, `avatar` TEXT, `walletCardNumber` TEXT, `walletId` TEXT, `walletTypeId` TEXT, `walletBalance` INTEGER, `canteenId` TEXT, `discountsList` TEXT, `discountsUpdateTime` TEXT, `createTime` TEXT, `updateTime` TEXT)'); await database.execute( - 'CREATE TABLE IF NOT EXISTS `face` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userId` TEXT NOT NULL, `sampleId` TEXT NOT NULL, `userName` TEXT NOT NULL, `deptId` TEXT, `deptName` TEXT, `imagePath` TEXT NOT NULL, `path` TEXT, `faceModel` TEXT, `createTime` TEXT, `updateTime` TEXT)'); + 'CREATE TABLE IF NOT EXISTS `face` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userId` TEXT NOT NULL, `sampleId` TEXT NOT NULL, `userName` TEXT NOT NULL, `deptId` TEXT, `deptName` TEXT, `imagePath` TEXT NOT NULL, `path` TEXT, `faceModel` TEXT, `createTime` TEXT, `updateTime` TEXT, `qualityScore` REAL, `qualityStatus` TEXT NOT NULL, `qualityCheckedAt` TEXT, `qualityErrorCode` INTEGER)'); await database.execute( 'CREATE TABLE IF NOT EXISTS `setting` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `userJson` TEXT NOT NULL, `sysJson` TEXT NOT NULL, `canteenExtraPriceConfigJson` TEXT)'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `scheduled_task` (`taskCode` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `cronExpression` TEXT NOT NULL, `idleOnly` INTEGER NOT NULL, `lastRunAt` TEXT, `lastSuccessAt` TEXT, `lastStatus` TEXT NOT NULL, `lastMessage` TEXT NOT NULL, `createTime` TEXT NOT NULL, `updateTime` TEXT NOT NULL, PRIMARY KEY (`taskCode`))'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `scheduled_task_log` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `taskCode` TEXT NOT NULL, `taskName` TEXT NOT NULL, `triggerType` TEXT NOT NULL, `sourceType` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `status` TEXT NOT NULL, `message` TEXT NOT NULL, `errorType` TEXT, `errorMessage` TEXT, `errorStack` TEXT, `startedAt` TEXT NOT NULL, `finishedAt` TEXT, `durationMs` INTEGER NOT NULL, `scannedCount` INTEGER, `skippedCount` INTEGER, `successCount` INTEGER, `failureCount` INTEGER)'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `file_upload_record` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `hardId` TEXT NOT NULL, `bizType` TEXT NOT NULL, `relativePath` TEXT NOT NULL, `originalName` TEXT NOT NULL, `sourceSize` INTEGER NOT NULL, `sourceModifiedTime` TEXT NOT NULL, `sanitizedHash` TEXT NOT NULL, `sanitizedSize` INTEGER NOT NULL, `status` TEXT NOT NULL, `serverFileId` TEXT, `attemptCount` INTEGER NOT NULL, `lastAttemptAt` TEXT, `uploadedAt` TEXT, `lastError` TEXT, `createTime` TEXT NOT NULL, `updateTime` TEXT NOT NULL)'); + await database.execute( + 'CREATE TABLE IF NOT EXISTS `order_evidence_upload_task` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `androidOrderCode` TEXT, `serverOrderId` TEXT, `recognitionSessionId` TEXT NOT NULL, `evidenceType` TEXT NOT NULL, `evidenceRole` TEXT NOT NULL, `sequenceNo` INTEGER NOT NULL, `userId` TEXT NOT NULL, `faceSampleId` TEXT, `referenceImageUrl` TEXT, `referenceImageVersion` TEXT, `localFilePath` TEXT, `imageHash` TEXT, `imageSize` INTEGER, `imageWidth` INTEGER, `imageHeight` INTEGER, `similarity` REAL, `similarityThreshold` REAL, `capturedAt` TEXT NOT NULL, `appVersion` TEXT NOT NULL, `status` TEXT NOT NULL, `attemptCount` INTEGER NOT NULL, `nextRetryAt` TEXT, `serverEvidenceId` TEXT, `serverImageUrl` TEXT, `lastError` TEXT, `unavailableReportedAt` TEXT, `completedAt` TEXT, `createTime` TEXT NOT NULL, `updateTime` TEXT NOT NULL, `expiresAt` TEXT NOT NULL)'); + await database.execute( + 'CREATE UNIQUE INDEX `index_print_history_servicePrintId` ON `print_history` (`servicePrintId`)'); + await database.execute( + 'CREATE INDEX `index_scheduled_task_log_taskCode_startedAt` ON `scheduled_task_log` (`taskCode`, `startedAt`)'); + await database.execute( + 'CREATE INDEX `index_scheduled_task_log_sourceType_status_startedAt` ON `scheduled_task_log` (`sourceType`, `status`, `startedAt`)'); + await database.execute( + 'CREATE UNIQUE INDEX `index_file_upload_record_hardId_bizType_relativePath` ON `file_upload_record` (`hardId`, `bizType`, `relativePath`)'); + await database.execute( + 'CREATE INDEX `index_file_upload_record_bizType_status_updateTime` ON `file_upload_record` (`bizType`, `status`, `updateTime`)'); + await database.execute( + 'CREATE UNIQUE INDEX `index_order_evidence_upload_task_androidOrderCode_evidenceType_evidenceRole_sequenceNo` ON `order_evidence_upload_task` (`androidOrderCode`, `evidenceType`, `evidenceRole`, `sequenceNo`)'); + await database.execute( + 'CREATE INDEX `index_order_evidence_upload_task_status_nextRetryAt_createTime` ON `order_evidence_upload_task` (`status`, `nextRetryAt`, `createTime`)'); await callback?.onCreate?.call(database, version); }, @@ -223,6 +253,30 @@ class _$AppDatabase extends AppDatabase { SettingDao get getSettingDao { return _getSettingDaoInstance ??= _$SettingDao(database, changeListener); } + + @override + ScheduledTaskDao get getScheduledTaskDao { + return _getScheduledTaskDaoInstance ??= + _$ScheduledTaskDao(database, changeListener); + } + + @override + ScheduledTaskLogDao get getScheduledTaskLogDao { + return _getScheduledTaskLogDaoInstance ??= + _$ScheduledTaskLogDao(database, changeListener); + } + + @override + FileUploadRecordDao get getFileUploadRecordDao { + return _getFileUploadRecordDaoInstance ??= + _$FileUploadRecordDao(database, changeListener); + } + + @override + OrderEvidenceUploadTaskDao get getOrderEvidenceUploadTaskDao { + return _getOrderEvidenceUploadTaskDaoInstance ??= + _$OrderEvidenceUploadTaskDao(database, changeListener); + } } class _$BroadcastDao extends BroadcastDao { @@ -1406,6 +1460,12 @@ class _$PrintHistoryDao extends PrintHistoryDao { 'serviceOrderId': item.serviceOrderId, 'servicePrintId': item.servicePrintId, 'invoiceUrl': item.invoiceUrl, + 'printType': item.printType, + 'printNo': item.printNo, + 'retryVersion': item.retryVersion, + 'attemptCount': item.attemptCount, + 'lastError': item.lastError, + 'resultPending': _boolConverter.encode(item.resultPending), 'status': _printStatusConverter.encode(item.status), 'printTime': item.printTime, 'syncTime': _dateTimeEmptyConverter.encode(item.syncTime), @@ -1421,6 +1481,12 @@ class _$PrintHistoryDao extends PrintHistoryDao { 'serviceOrderId': item.serviceOrderId, 'servicePrintId': item.servicePrintId, 'invoiceUrl': item.invoiceUrl, + 'printType': item.printType, + 'printNo': item.printNo, + 'retryVersion': item.retryVersion, + 'attemptCount': item.attemptCount, + 'lastError': item.lastError, + 'resultPending': _boolConverter.encode(item.resultPending), 'status': _printStatusConverter.encode(item.status), 'printTime': item.printTime, 'syncTime': _dateTimeEmptyConverter.encode(item.syncTime), @@ -1436,6 +1502,12 @@ class _$PrintHistoryDao extends PrintHistoryDao { 'serviceOrderId': item.serviceOrderId, 'servicePrintId': item.servicePrintId, 'invoiceUrl': item.invoiceUrl, + 'printType': item.printType, + 'printNo': item.printNo, + 'retryVersion': item.retryVersion, + 'attemptCount': item.attemptCount, + 'lastError': item.lastError, + 'resultPending': _boolConverter.encode(item.resultPending), 'status': _printStatusConverter.encode(item.status), 'printTime': item.printTime, 'syncTime': _dateTimeEmptyConverter.encode(item.syncTime), @@ -1464,10 +1536,97 @@ class _$PrintHistoryDao extends PrintHistoryDao { Future> getAll(String status) async { return _queryAdapter.queryList( 'SELECT * FROM print_history WHERE (?1 = \'-1\' OR status = ?1)', - mapper: (Map row) => PrintHistoryEntity(id: row['id'] as int?, orderId: row['orderId'] as String, serviceOrderId: row['serviceOrderId'] as String, servicePrintId: row['servicePrintId'] as String, invoiceUrl: row['invoiceUrl'] as String, status: _printStatusConverter.decode(row['status'] as String), printTime: row['printTime'] as String?, syncTime: _dateTimeEmptyConverter.decode(row['syncTime'] as String?), createTime: _dateTimeConverter.decode(row['createTime'] as String)), + mapper: (Map row) => PrintHistoryEntity(id: row['id'] as int?, orderId: row['orderId'] as String, serviceOrderId: row['serviceOrderId'] as String, servicePrintId: row['servicePrintId'] as String, invoiceUrl: row['invoiceUrl'] as String, printType: row['printType'] as String, printNo: row['printNo'] as int, retryVersion: row['retryVersion'] as int, attemptCount: row['attemptCount'] as int, lastError: row['lastError'] as String?, resultPending: _boolConverter.decode(row['resultPending'] as int), status: _printStatusConverter.decode(row['status'] as String), printTime: row['printTime'] as String?, syncTime: _dateTimeEmptyConverter.decode(row['syncTime'] as String?), createTime: _dateTimeConverter.decode(row['createTime'] as String)), arguments: [status]); } + @override + Future findByServicePrintId( + String servicePrintId) async { + return _queryAdapter.query( + 'SELECT * FROM print_history WHERE servicePrintId = ?1 LIMIT 1', + mapper: (Map row) => PrintHistoryEntity( + id: row['id'] as int?, + orderId: row['orderId'] as String, + serviceOrderId: row['serviceOrderId'] as String, + servicePrintId: row['servicePrintId'] as String, + invoiceUrl: row['invoiceUrl'] as String, + printType: row['printType'] as String, + printNo: row['printNo'] as int, + retryVersion: row['retryVersion'] as int, + attemptCount: row['attemptCount'] as int, + lastError: row['lastError'] as String?, + resultPending: _boolConverter.decode(row['resultPending'] as int), + status: _printStatusConverter.decode(row['status'] as String), + printTime: row['printTime'] as String?, + syncTime: + _dateTimeEmptyConverter.decode(row['syncTime'] as String?), + createTime: _dateTimeConverter.decode(row['createTime'] as String)), + arguments: [servicePrintId]); + } + + @override + Future> getPendingResults() async { + return _queryAdapter.queryList( + 'SELECT * FROM print_history WHERE resultPending = 1 ORDER BY createTime ASC', + mapper: (Map row) => PrintHistoryEntity( + id: row['id'] as int?, + orderId: row['orderId'] as String, + serviceOrderId: row['serviceOrderId'] as String, + servicePrintId: row['servicePrintId'] as String, + invoiceUrl: row['invoiceUrl'] as String, + printType: row['printType'] as String, + printNo: row['printNo'] as int, + retryVersion: row['retryVersion'] as int, + attemptCount: row['attemptCount'] as int, + lastError: row['lastError'] as String?, + resultPending: _boolConverter.decode(row['resultPending'] as int), + status: _printStatusConverter.decode(row['status'] as String), + printTime: row['printTime'] as String?, + syncTime: + _dateTimeEmptyConverter.decode(row['syncTime'] as String?), + createTime: + _dateTimeConverter.decode(row['createTime'] as String))); + } + + @override + Future countPending() async { + return _queryAdapter.query( + 'SELECT COUNT(*) FROM print_history WHERE status = \'NONE\'', + mapper: (Map row) => row.values.first as int); + } + + @override + Future countFailed() async { + return _queryAdapter.query( + 'SELECT COUNT(*) FROM print_history WHERE status = \'ERROR\'', + mapper: (Map row) => row.values.first as int); + } + + @override + Future findLatestError() async { + return _queryAdapter.query( + 'SELECT * FROM print_history WHERE lastError IS NOT NULL AND lastError != \'\' ORDER BY id DESC LIMIT 1', + mapper: (Map row) => PrintHistoryEntity( + id: row['id'] as int?, + orderId: row['orderId'] as String, + serviceOrderId: row['serviceOrderId'] as String, + servicePrintId: row['servicePrintId'] as String, + invoiceUrl: row['invoiceUrl'] as String, + printType: row['printType'] as String, + printNo: row['printNo'] as int, + retryVersion: row['retryVersion'] as int, + attemptCount: row['attemptCount'] as int, + lastError: row['lastError'] as String?, + resultPending: _boolConverter.decode(row['resultPending'] as int), + status: _printStatusConverter.decode(row['status'] as String), + printTime: row['printTime'] as String?, + syncTime: + _dateTimeEmptyConverter.decode(row['syncTime'] as String?), + createTime: + _dateTimeConverter.decode(row['createTime'] as String))); + } + @override Future save(PrintHistoryEntity entity) async { await _printHistoryEntityInsertionAdapter.insert( @@ -1690,7 +1849,12 @@ class _$FaceDao extends FaceDao { 'path': item.path, 'faceModel': _listIntConverter.encode(item.faceModel), 'createTime': _dateTimeEmptyConverter.encode(item.createTime), - 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime) + 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime), + 'qualityScore': item.qualityScore, + 'qualityStatus': item.qualityStatus, + 'qualityCheckedAt': + _dateTimeEmptyConverter.encode(item.qualityCheckedAt), + 'qualityErrorCode': item.qualityErrorCode }), _faceEntityUpdateAdapter = UpdateAdapter( database, @@ -1707,7 +1871,12 @@ class _$FaceDao extends FaceDao { 'path': item.path, 'faceModel': _listIntConverter.encode(item.faceModel), 'createTime': _dateTimeEmptyConverter.encode(item.createTime), - 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime) + 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime), + 'qualityScore': item.qualityScore, + 'qualityStatus': item.qualityStatus, + 'qualityCheckedAt': + _dateTimeEmptyConverter.encode(item.qualityCheckedAt), + 'qualityErrorCode': item.qualityErrorCode }), _faceEntityDeletionAdapter = DeletionAdapter( database, @@ -1724,7 +1893,12 @@ class _$FaceDao extends FaceDao { 'path': item.path, 'faceModel': _listIntConverter.encode(item.faceModel), 'createTime': _dateTimeEmptyConverter.encode(item.createTime), - 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime) + 'updateTime': _dateTimeEmptyConverter.encode(item.updateTime), + 'qualityScore': item.qualityScore, + 'qualityStatus': item.qualityStatus, + 'qualityCheckedAt': + _dateTimeEmptyConverter.encode(item.qualityCheckedAt), + 'qualityErrorCode': item.qualityErrorCode }); final sqflite.DatabaseExecutor database; @@ -1770,6 +1944,77 @@ class _$FaceDao extends FaceDao { arguments: [userName]); } + @override + Future findLowQualityCount( + String userName, + double threshold, + ) async { + return _queryAdapter.query( + 'SELECT COUNT(0) FROM face WHERE qualityStatus = \'detected\' AND qualityScore IS NOT NULL AND qualityScore < ?2 AND (?1 = \'-1\' OR userName LIKE \'%\' || ?1 || \'%\')', + mapper: (Map row) => row.values.first as int, + arguments: [userName, threshold]); + } + + @override + Future> findLowQuality( + String userName, + double threshold, + int pageSize, + int offset, + ) async { + return _queryAdapter.queryList( + 'SELECT * FROM face WHERE qualityStatus = \'detected\' AND qualityScore IS NOT NULL AND qualityScore < ?2 AND (?1 = \'-1\' OR userName LIKE \'%\' || ?1 || \'%\') ORDER BY id ASC LIMIT ?3 OFFSET ?4', + mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String?), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter.decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?), + arguments: [userName, threshold, pageSize, offset]); + } + + @override + Future> findAllLowQuality(double threshold) async { + return _queryAdapter.queryList( + 'SELECT * FROM face WHERE qualityStatus = \'detected\' AND qualityScore IS NOT NULL AND qualityScore < ?1 ORDER BY id ASC', + mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String?), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter.decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?), + arguments: [threshold]); + } + + @override + Future findPendingQualityCount() async { + return _queryAdapter.query( + 'SELECT COUNT(0) FROM face WHERE qualityStatus = \'pending\'', + mapper: (Map row) => row.values.first as int); + } + + @override + Future findFailedQualityCount() async { + return _queryAdapter.query( + 'SELECT COUNT(0) FROM face WHERE qualityStatus = \'failed\'', + mapper: (Map row) => row.values.first as int); + } + + @override + Future> findQualityAuditCandidates() async { + return _queryAdapter.queryList( + 'SELECT * FROM face WHERE qualityStatus IN (\'pending\', \'failed\') ORDER BY id ASC', + mapper: (Map row) => FaceEntity( + id: row['id'] as int?, + userId: row['userId'] as String, + sampleId: row['sampleId'] as String, + userName: row['userName'] as String, + deptId: row['deptId'] as String?, + deptName: row['deptName'] as String?, + imagePath: row['imagePath'] as String, + path: row['path'] as String?, + faceModel: _listIntConverter.decode(row['faceModel'] as String), + createTime: + _dateTimeEmptyConverter.decode(row['createTime'] as String?), + updateTime: + _dateTimeEmptyConverter.decode(row['updateTime'] as String?), + qualityScore: row['qualityScore'] as double?, + qualityStatus: row['qualityStatus'] as String, + qualityCheckedAt: _dateTimeEmptyConverter + .decode(row['qualityCheckedAt'] as String?), + qualityErrorCode: row['qualityErrorCode'] as int?)); + } + @override Future getById(int id) async { return _queryAdapter.query('SELECT * FROM face WHERE id = ?1', @@ -1786,7 +2031,12 @@ class _$FaceDao extends FaceDao { createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String?), updateTime: - _dateTimeEmptyConverter.decode(row['updateTime'] as String?)), + _dateTimeEmptyConverter.decode(row['updateTime'] as String?), + qualityScore: row['qualityScore'] as double?, + qualityStatus: row['qualityStatus'] as String, + qualityCheckedAt: _dateTimeEmptyConverter + .decode(row['qualityCheckedAt'] as String?), + qualityErrorCode: row['qualityErrorCode'] as int?), arguments: [id]); } @@ -1806,7 +2056,12 @@ class _$FaceDao extends FaceDao { createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String?), updateTime: - _dateTimeEmptyConverter.decode(row['updateTime'] as String?)), + _dateTimeEmptyConverter.decode(row['updateTime'] as String?), + qualityScore: row['qualityScore'] as double?, + qualityStatus: row['qualityStatus'] as String, + qualityCheckedAt: _dateTimeEmptyConverter + .decode(row['qualityCheckedAt'] as String?), + qualityErrorCode: row['qualityErrorCode'] as int?), arguments: [sampleId]); } @@ -1826,7 +2081,12 @@ class _$FaceDao extends FaceDao { createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String?), updateTime: - _dateTimeEmptyConverter.decode(row['updateTime'] as String?)), + _dateTimeEmptyConverter.decode(row['updateTime'] as String?), + qualityScore: row['qualityScore'] as double?, + qualityStatus: row['qualityStatus'] as String, + qualityCheckedAt: _dateTimeEmptyConverter + .decode(row['qualityCheckedAt'] as String?), + qualityErrorCode: row['qualityErrorCode'] as int?), arguments: [userId]); } @@ -1838,10 +2098,34 @@ class _$FaceDao extends FaceDao { ) async { return _queryAdapter.queryList( 'SELECT * FROM face WHERE (?1 = \'-1\' OR userName LIKE \'%\' || ?1 || \'%\') LIMIT ?2 OFFSET ?3', - mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String?), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String?)), + mapper: (Map row) => FaceEntity(id: row['id'] as int?, userId: row['userId'] as String, sampleId: row['sampleId'] as String, userName: row['userName'] as String, deptId: row['deptId'] as String?, deptName: row['deptName'] as String?, imagePath: row['imagePath'] as String, path: row['path'] as String?, faceModel: _listIntConverter.decode(row['faceModel'] as String), createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String?), updateTime: _dateTimeEmptyConverter.decode(row['updateTime'] as String?), qualityScore: row['qualityScore'] as double?, qualityStatus: row['qualityStatus'] as String, qualityCheckedAt: _dateTimeEmptyConverter.decode(row['qualityCheckedAt'] as String?), qualityErrorCode: row['qualityErrorCode'] as int?), arguments: [userName, pageSize, offset]); } + @override + Future> getAll() async { + return _queryAdapter.queryList('SELECT * FROM face ORDER BY sampleId ASC', + mapper: (Map row) => FaceEntity( + id: row['id'] as int?, + userId: row['userId'] as String, + sampleId: row['sampleId'] as String, + userName: row['userName'] as String, + deptId: row['deptId'] as String?, + deptName: row['deptName'] as String?, + imagePath: row['imagePath'] as String, + path: row['path'] as String?, + faceModel: _listIntConverter.decode(row['faceModel'] as String), + createTime: + _dateTimeEmptyConverter.decode(row['createTime'] as String?), + updateTime: + _dateTimeEmptyConverter.decode(row['updateTime'] as String?), + qualityScore: row['qualityScore'] as double?, + qualityStatus: row['qualityStatus'] as String, + qualityCheckedAt: _dateTimeEmptyConverter + .decode(row['qualityCheckedAt'] as String?), + qualityErrorCode: row['qualityErrorCode'] as int?)); + } + @override Future> findAllUserIds() async { return _queryAdapter.queryList( @@ -1866,7 +2150,12 @@ class _$FaceDao extends FaceDao { createTime: _dateTimeEmptyConverter.decode(row['createTime'] as String?), updateTime: - _dateTimeEmptyConverter.decode(row['updateTime'] as String?))); + _dateTimeEmptyConverter.decode(row['updateTime'] as String?), + qualityScore: row['qualityScore'] as double?, + qualityStatus: row['qualityStatus'] as String, + qualityCheckedAt: _dateTimeEmptyConverter + .decode(row['qualityCheckedAt'] as String?), + qualityErrorCode: row['qualityErrorCode'] as int?)); } @override @@ -1981,6 +2270,588 @@ class _$SettingDao extends SettingDao { } } +class _$ScheduledTaskDao extends ScheduledTaskDao { + _$ScheduledTaskDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _scheduledTaskEntityInsertionAdapter = InsertionAdapter( + database, + 'scheduled_task', + (ScheduledTaskEntity item) => { + 'taskCode': item.taskCode, + 'enabled': _boolConverter.encode(item.enabled), + 'cronExpression': item.cronExpression, + 'idleOnly': _boolConverter.encode(item.idleOnly), + 'lastRunAt': _dateTimeEmptyConverter.encode(item.lastRunAt), + 'lastSuccessAt': + _dateTimeEmptyConverter.encode(item.lastSuccessAt), + 'lastStatus': item.lastStatus, + 'lastMessage': item.lastMessage, + 'createTime': _dateTimeConverter.encode(item.createTime), + 'updateTime': _dateTimeConverter.encode(item.updateTime) + }), + _scheduledTaskEntityUpdateAdapter = UpdateAdapter( + database, + 'scheduled_task', + ['taskCode'], + (ScheduledTaskEntity item) => { + 'taskCode': item.taskCode, + 'enabled': _boolConverter.encode(item.enabled), + 'cronExpression': item.cronExpression, + 'idleOnly': _boolConverter.encode(item.idleOnly), + 'lastRunAt': _dateTimeEmptyConverter.encode(item.lastRunAt), + 'lastSuccessAt': + _dateTimeEmptyConverter.encode(item.lastSuccessAt), + 'lastStatus': item.lastStatus, + 'lastMessage': item.lastMessage, + 'createTime': _dateTimeConverter.encode(item.createTime), + 'updateTime': _dateTimeConverter.encode(item.updateTime) + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter + _scheduledTaskEntityInsertionAdapter; + + final UpdateAdapter _scheduledTaskEntityUpdateAdapter; + + @override + Future> getAll() async { + return _queryAdapter.queryList( + 'SELECT * FROM scheduled_task ORDER BY taskCode', + mapper: (Map row) => ScheduledTaskEntity( + taskCode: row['taskCode'] as String, + enabled: _boolConverter.decode(row['enabled'] as int), + cronExpression: row['cronExpression'] as String, + idleOnly: _boolConverter.decode(row['idleOnly'] as int), + lastRunAt: + _dateTimeEmptyConverter.decode(row['lastRunAt'] as String?), + lastSuccessAt: + _dateTimeEmptyConverter.decode(row['lastSuccessAt'] as String?), + lastStatus: row['lastStatus'] as String, + lastMessage: row['lastMessage'] as String, + createTime: _dateTimeConverter.decode(row['createTime'] as String), + updateTime: + _dateTimeConverter.decode(row['updateTime'] as String))); + } + + @override + Future getByCode(String taskCode) async { + return _queryAdapter.query( + 'SELECT * FROM scheduled_task WHERE taskCode = ?1 LIMIT 1', + mapper: (Map row) => ScheduledTaskEntity( + taskCode: row['taskCode'] as String, + enabled: _boolConverter.decode(row['enabled'] as int), + cronExpression: row['cronExpression'] as String, + idleOnly: _boolConverter.decode(row['idleOnly'] as int), + lastRunAt: + _dateTimeEmptyConverter.decode(row['lastRunAt'] as String?), + lastSuccessAt: + _dateTimeEmptyConverter.decode(row['lastSuccessAt'] as String?), + lastStatus: row['lastStatus'] as String, + lastMessage: row['lastMessage'] as String, + createTime: _dateTimeConverter.decode(row['createTime'] as String), + updateTime: _dateTimeConverter.decode(row['updateTime'] as String)), + arguments: [taskCode]); + } + + @override + Future save(ScheduledTaskEntity entity) async { + await _scheduledTaskEntityInsertionAdapter.insert( + entity, OnConflictStrategy.replace); + } + + @override + Future batchSave(List entities) async { + await _scheduledTaskEntityInsertionAdapter.insertList( + entities, OnConflictStrategy.replace); + } + + @override + Future edit(ScheduledTaskEntity entity) async { + await _scheduledTaskEntityUpdateAdapter.update( + entity, OnConflictStrategy.abort); + } +} + +class _$ScheduledTaskLogDao extends ScheduledTaskLogDao { + _$ScheduledTaskLogDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _scheduledTaskLogEntityInsertionAdapter = InsertionAdapter( + database, + 'scheduled_task_log', + (ScheduledTaskLogEntity item) => { + 'id': item.id, + 'taskCode': item.taskCode, + 'taskName': item.taskName, + 'triggerType': item.triggerType, + 'sourceType': item.sourceType, + 'sourceName': item.sourceName, + 'status': item.status, + 'message': item.message, + 'errorType': item.errorType, + 'errorMessage': item.errorMessage, + 'errorStack': item.errorStack, + 'startedAt': _dateTimeConverter.encode(item.startedAt), + 'finishedAt': _dateTimeEmptyConverter.encode(item.finishedAt), + 'durationMs': item.durationMs, + 'scannedCount': item.scannedCount, + 'skippedCount': item.skippedCount, + 'successCount': item.successCount, + 'failureCount': item.failureCount + }), + _scheduledTaskLogEntityUpdateAdapter = UpdateAdapter( + database, + 'scheduled_task_log', + ['id'], + (ScheduledTaskLogEntity item) => { + 'id': item.id, + 'taskCode': item.taskCode, + 'taskName': item.taskName, + 'triggerType': item.triggerType, + 'sourceType': item.sourceType, + 'sourceName': item.sourceName, + 'status': item.status, + 'message': item.message, + 'errorType': item.errorType, + 'errorMessage': item.errorMessage, + 'errorStack': item.errorStack, + 'startedAt': _dateTimeConverter.encode(item.startedAt), + 'finishedAt': _dateTimeEmptyConverter.encode(item.finishedAt), + 'durationMs': item.durationMs, + 'scannedCount': item.scannedCount, + 'skippedCount': item.skippedCount, + 'successCount': item.successCount, + 'failureCount': item.failureCount + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter + _scheduledTaskLogEntityInsertionAdapter; + + final UpdateAdapter + _scheduledTaskLogEntityUpdateAdapter; + + @override + Future> getByTask( + String taskCode, + String sourceType, + int limit, + ) async { + return _queryAdapter.queryList( + 'SELECT * FROM scheduled_task_log WHERE taskCode = ?1 AND (?2 = \'\' OR sourceType = ?2) ORDER BY id DESC LIMIT ?3', + mapper: (Map row) => ScheduledTaskLogEntity(id: row['id'] as int?, taskCode: row['taskCode'] as String, taskName: row['taskName'] as String, triggerType: row['triggerType'] as String, sourceType: row['sourceType'] as String, sourceName: row['sourceName'] as String, status: row['status'] as String, message: row['message'] as String, errorType: row['errorType'] as String?, errorMessage: row['errorMessage'] as String?, errorStack: row['errorStack'] as String?, startedAt: _dateTimeConverter.decode(row['startedAt'] as String), finishedAt: _dateTimeEmptyConverter.decode(row['finishedAt'] as String?), durationMs: row['durationMs'] as int, scannedCount: row['scannedCount'] as int?, skippedCount: row['skippedCount'] as int?, successCount: row['successCount'] as int?, failureCount: row['failureCount'] as int?), + arguments: [taskCode, sourceType, limit]); + } + + @override + Future> getPageByTask( + String taskCode, + String sourceType, + int pageSize, + int offset, + ) async { + return _queryAdapter.queryList( + 'SELECT * FROM scheduled_task_log WHERE taskCode = ?1 AND (?2 = \'\' OR sourceType = ?2) ORDER BY id DESC LIMIT ?3 OFFSET ?4', + mapper: (Map row) => ScheduledTaskLogEntity(id: row['id'] as int?, taskCode: row['taskCode'] as String, taskName: row['taskName'] as String, triggerType: row['triggerType'] as String, sourceType: row['sourceType'] as String, sourceName: row['sourceName'] as String, status: row['status'] as String, message: row['message'] as String, errorType: row['errorType'] as String?, errorMessage: row['errorMessage'] as String?, errorStack: row['errorStack'] as String?, startedAt: _dateTimeConverter.decode(row['startedAt'] as String), finishedAt: _dateTimeEmptyConverter.decode(row['finishedAt'] as String?), durationMs: row['durationMs'] as int, scannedCount: row['scannedCount'] as int?, skippedCount: row['skippedCount'] as int?, successCount: row['successCount'] as int?, failureCount: row['failureCount'] as int?), + arguments: [taskCode, sourceType, pageSize, offset]); + } + + @override + Future countByTask( + String taskCode, + String sourceType, + ) async { + return _queryAdapter.query( + 'SELECT COUNT(0) FROM scheduled_task_log WHERE taskCode = ?1 AND (?2 = \'\' OR sourceType = ?2)', + mapper: (Map row) => row.values.first as int, + arguments: [taskCode, sourceType]); + } + + @override + Future getLatest(String taskCode) async { + return _queryAdapter.query( + 'SELECT * FROM scheduled_task_log WHERE taskCode = ?1 ORDER BY id DESC LIMIT 1', + mapper: (Map row) => ScheduledTaskLogEntity(id: row['id'] as int?, taskCode: row['taskCode'] as String, taskName: row['taskName'] as String, triggerType: row['triggerType'] as String, sourceType: row['sourceType'] as String, sourceName: row['sourceName'] as String, status: row['status'] as String, message: row['message'] as String, errorType: row['errorType'] as String?, errorMessage: row['errorMessage'] as String?, errorStack: row['errorStack'] as String?, startedAt: _dateTimeConverter.decode(row['startedAt'] as String), finishedAt: _dateTimeEmptyConverter.decode(row['finishedAt'] as String?), durationMs: row['durationMs'] as int, scannedCount: row['scannedCount'] as int?, skippedCount: row['skippedCount'] as int?, successCount: row['successCount'] as int?, failureCount: row['failureCount'] as int?), + arguments: [taskCode]); + } + + @override + Future trimHistory() async { + await _queryAdapter.queryNoReturn( + 'DELETE FROM scheduled_task_log WHERE id NOT IN ( SELECT id FROM scheduled_task_log ORDER BY id DESC LIMIT 2000 )'); + } + + @override + Future save(ScheduledTaskLogEntity entity) { + return _scheduledTaskLogEntityInsertionAdapter.insertAndReturnId( + entity, OnConflictStrategy.abort); + } + + @override + Future edit(ScheduledTaskLogEntity entity) async { + await _scheduledTaskLogEntityUpdateAdapter.update( + entity, OnConflictStrategy.abort); + } +} + +class _$FileUploadRecordDao extends FileUploadRecordDao { + _$FileUploadRecordDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _fileUploadRecordEntityInsertionAdapter = InsertionAdapter( + database, + 'file_upload_record', + (FileUploadRecordEntity item) => { + 'id': item.id, + 'hardId': item.hardId, + 'bizType': item.bizType, + 'relativePath': item.relativePath, + 'originalName': item.originalName, + 'sourceSize': item.sourceSize, + 'sourceModifiedTime': + _dateTimeConverter.encode(item.sourceModifiedTime), + 'sanitizedHash': item.sanitizedHash, + 'sanitizedSize': item.sanitizedSize, + 'status': item.status, + 'serverFileId': item.serverFileId, + 'attemptCount': item.attemptCount, + 'lastAttemptAt': + _dateTimeEmptyConverter.encode(item.lastAttemptAt), + 'uploadedAt': _dateTimeEmptyConverter.encode(item.uploadedAt), + 'lastError': item.lastError, + 'createTime': _dateTimeConverter.encode(item.createTime), + 'updateTime': _dateTimeConverter.encode(item.updateTime) + }), + _fileUploadRecordEntityUpdateAdapter = UpdateAdapter( + database, + 'file_upload_record', + ['id'], + (FileUploadRecordEntity item) => { + 'id': item.id, + 'hardId': item.hardId, + 'bizType': item.bizType, + 'relativePath': item.relativePath, + 'originalName': item.originalName, + 'sourceSize': item.sourceSize, + 'sourceModifiedTime': + _dateTimeConverter.encode(item.sourceModifiedTime), + 'sanitizedHash': item.sanitizedHash, + 'sanitizedSize': item.sanitizedSize, + 'status': item.status, + 'serverFileId': item.serverFileId, + 'attemptCount': item.attemptCount, + 'lastAttemptAt': + _dateTimeEmptyConverter.encode(item.lastAttemptAt), + 'uploadedAt': _dateTimeEmptyConverter.encode(item.uploadedAt), + 'lastError': item.lastError, + 'createTime': _dateTimeConverter.encode(item.createTime), + 'updateTime': _dateTimeConverter.encode(item.updateTime) + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter + _fileUploadRecordEntityInsertionAdapter; + + final UpdateAdapter + _fileUploadRecordEntityUpdateAdapter; + + @override + Future> getAll( + String hardId, + String bizType, + ) async { + return _queryAdapter.queryList( + 'SELECT * FROM file_upload_record WHERE hardId = ?1 AND bizType = ?2 ORDER BY updateTime DESC', + mapper: (Map row) => FileUploadRecordEntity(id: row['id'] as int?, hardId: row['hardId'] as String, bizType: row['bizType'] as String, relativePath: row['relativePath'] as String, originalName: row['originalName'] as String, sourceSize: row['sourceSize'] as int, sourceModifiedTime: _dateTimeConverter.decode(row['sourceModifiedTime'] as String), sanitizedHash: row['sanitizedHash'] as String, sanitizedSize: row['sanitizedSize'] as int, status: row['status'] as String, serverFileId: row['serverFileId'] as String?, attemptCount: row['attemptCount'] as int, lastAttemptAt: _dateTimeEmptyConverter.decode(row['lastAttemptAt'] as String?), uploadedAt: _dateTimeEmptyConverter.decode(row['uploadedAt'] as String?), lastError: row['lastError'] as String?, createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String)), + arguments: [hardId, bizType]); + } + + @override + Future getByPath( + String hardId, + String bizType, + String relativePath, + ) async { + return _queryAdapter.query( + 'SELECT * FROM file_upload_record WHERE hardId = ?1 AND bizType = ?2 AND relativePath = ?3 LIMIT 1', + mapper: (Map row) => FileUploadRecordEntity(id: row['id'] as int?, hardId: row['hardId'] as String, bizType: row['bizType'] as String, relativePath: row['relativePath'] as String, originalName: row['originalName'] as String, sourceSize: row['sourceSize'] as int, sourceModifiedTime: _dateTimeConverter.decode(row['sourceModifiedTime'] as String), sanitizedHash: row['sanitizedHash'] as String, sanitizedSize: row['sanitizedSize'] as int, status: row['status'] as String, serverFileId: row['serverFileId'] as String?, attemptCount: row['attemptCount'] as int, lastAttemptAt: _dateTimeEmptyConverter.decode(row['lastAttemptAt'] as String?), uploadedAt: _dateTimeEmptyConverter.decode(row['uploadedAt'] as String?), lastError: row['lastError'] as String?, createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String)), + arguments: [hardId, bizType, relativePath]); + } + + @override + Future save(FileUploadRecordEntity entity) { + return _fileUploadRecordEntityInsertionAdapter.insertAndReturnId( + entity, OnConflictStrategy.replace); + } + + @override + Future edit(FileUploadRecordEntity entity) async { + await _fileUploadRecordEntityUpdateAdapter.update( + entity, OnConflictStrategy.abort); + } +} + +class _$OrderEvidenceUploadTaskDao extends OrderEvidenceUploadTaskDao { + _$OrderEvidenceUploadTaskDao( + this.database, + this.changeListener, + ) : _queryAdapter = QueryAdapter(database), + _orderEvidenceUploadTaskEntityInsertionAdapter = InsertionAdapter( + database, + 'order_evidence_upload_task', + (OrderEvidenceUploadTaskEntity item) => { + 'id': item.id, + 'androidOrderCode': item.androidOrderCode, + 'serverOrderId': item.serverOrderId, + 'recognitionSessionId': item.recognitionSessionId, + 'evidenceType': item.evidenceType, + 'evidenceRole': item.evidenceRole, + 'sequenceNo': item.sequenceNo, + 'userId': item.userId, + 'faceSampleId': item.faceSampleId, + 'referenceImageUrl': item.referenceImageUrl, + 'referenceImageVersion': item.referenceImageVersion, + 'localFilePath': item.localFilePath, + 'imageHash': item.imageHash, + 'imageSize': item.imageSize, + 'imageWidth': item.imageWidth, + 'imageHeight': item.imageHeight, + 'similarity': item.similarity, + 'similarityThreshold': item.similarityThreshold, + 'capturedAt': _dateTimeConverter.encode(item.capturedAt), + 'appVersion': item.appVersion, + 'status': item.status, + 'attemptCount': item.attemptCount, + 'nextRetryAt': + _dateTimeEmptyConverter.encode(item.nextRetryAt), + 'serverEvidenceId': item.serverEvidenceId, + 'serverImageUrl': item.serverImageUrl, + 'lastError': item.lastError, + 'unavailableReportedAt': _dateTimeEmptyConverter + .encode(item.unavailableReportedAt), + 'completedAt': + _dateTimeEmptyConverter.encode(item.completedAt), + 'createTime': _dateTimeConverter.encode(item.createTime), + 'updateTime': _dateTimeConverter.encode(item.updateTime), + 'expiresAt': _dateTimeConverter.encode(item.expiresAt) + }), + _orderEvidenceUploadTaskEntityUpdateAdapter = UpdateAdapter( + database, + 'order_evidence_upload_task', + ['id'], + (OrderEvidenceUploadTaskEntity item) => { + 'id': item.id, + 'androidOrderCode': item.androidOrderCode, + 'serverOrderId': item.serverOrderId, + 'recognitionSessionId': item.recognitionSessionId, + 'evidenceType': item.evidenceType, + 'evidenceRole': item.evidenceRole, + 'sequenceNo': item.sequenceNo, + 'userId': item.userId, + 'faceSampleId': item.faceSampleId, + 'referenceImageUrl': item.referenceImageUrl, + 'referenceImageVersion': item.referenceImageVersion, + 'localFilePath': item.localFilePath, + 'imageHash': item.imageHash, + 'imageSize': item.imageSize, + 'imageWidth': item.imageWidth, + 'imageHeight': item.imageHeight, + 'similarity': item.similarity, + 'similarityThreshold': item.similarityThreshold, + 'capturedAt': _dateTimeConverter.encode(item.capturedAt), + 'appVersion': item.appVersion, + 'status': item.status, + 'attemptCount': item.attemptCount, + 'nextRetryAt': + _dateTimeEmptyConverter.encode(item.nextRetryAt), + 'serverEvidenceId': item.serverEvidenceId, + 'serverImageUrl': item.serverImageUrl, + 'lastError': item.lastError, + 'unavailableReportedAt': _dateTimeEmptyConverter + .encode(item.unavailableReportedAt), + 'completedAt': + _dateTimeEmptyConverter.encode(item.completedAt), + 'createTime': _dateTimeConverter.encode(item.createTime), + 'updateTime': _dateTimeConverter.encode(item.updateTime), + 'expiresAt': _dateTimeConverter.encode(item.expiresAt) + }); + + final sqflite.DatabaseExecutor database; + + final StreamController changeListener; + + final QueryAdapter _queryAdapter; + + final InsertionAdapter + _orderEvidenceUploadTaskEntityInsertionAdapter; + + final UpdateAdapter + _orderEvidenceUploadTaskEntityUpdateAdapter; + + @override + Future getByPosition( + String androidOrderCode, + String evidenceType, + String evidenceRole, + int sequenceNo, + ) async { + return _queryAdapter.query( + 'SELECT * FROM order_evidence_upload_task WHERE androidOrderCode = ?1 AND evidenceType = ?2 AND evidenceRole = ?3 AND sequenceNo = ?4 LIMIT 1', + mapper: (Map row) => OrderEvidenceUploadTaskEntity(id: row['id'] as int?, androidOrderCode: row['androidOrderCode'] as String?, serverOrderId: row['serverOrderId'] as String?, recognitionSessionId: row['recognitionSessionId'] as String, evidenceType: row['evidenceType'] as String, evidenceRole: row['evidenceRole'] as String, sequenceNo: row['sequenceNo'] as int, userId: row['userId'] as String, faceSampleId: row['faceSampleId'] as String?, referenceImageUrl: row['referenceImageUrl'] as String?, referenceImageVersion: row['referenceImageVersion'] as String?, localFilePath: row['localFilePath'] as String?, imageHash: row['imageHash'] as String?, imageSize: row['imageSize'] as int?, imageWidth: row['imageWidth'] as int?, imageHeight: row['imageHeight'] as int?, similarity: row['similarity'] as double?, similarityThreshold: row['similarityThreshold'] as double?, capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), appVersion: row['appVersion'] as String, status: row['status'] as String, attemptCount: row['attemptCount'] as int, nextRetryAt: _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String?), serverEvidenceId: row['serverEvidenceId'] as String?, serverImageUrl: row['serverImageUrl'] as String?, lastError: row['lastError'] as String?, unavailableReportedAt: _dateTimeEmptyConverter.decode(row['unavailableReportedAt'] as String?), completedAt: _dateTimeEmptyConverter.decode(row['completedAt'] as String?), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String)), + arguments: [androidOrderCode, evidenceType, evidenceRole, sequenceNo]); + } + + @override + Future getByServerOrderPosition( + String serverOrderId, + String evidenceType, + String evidenceRole, + int sequenceNo, + ) async { + return _queryAdapter.query( + 'SELECT * FROM order_evidence_upload_task WHERE serverOrderId = ?1 AND evidenceType = ?2 AND evidenceRole = ?3 AND sequenceNo = ?4 LIMIT 1', + mapper: (Map row) => OrderEvidenceUploadTaskEntity(id: row['id'] as int?, androidOrderCode: row['androidOrderCode'] as String?, serverOrderId: row['serverOrderId'] as String?, recognitionSessionId: row['recognitionSessionId'] as String, evidenceType: row['evidenceType'] as String, evidenceRole: row['evidenceRole'] as String, sequenceNo: row['sequenceNo'] as int, userId: row['userId'] as String, faceSampleId: row['faceSampleId'] as String?, referenceImageUrl: row['referenceImageUrl'] as String?, referenceImageVersion: row['referenceImageVersion'] as String?, localFilePath: row['localFilePath'] as String?, imageHash: row['imageHash'] as String?, imageSize: row['imageSize'] as int?, imageWidth: row['imageWidth'] as int?, imageHeight: row['imageHeight'] as int?, similarity: row['similarity'] as double?, similarityThreshold: row['similarityThreshold'] as double?, capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), appVersion: row['appVersion'] as String, status: row['status'] as String, attemptCount: row['attemptCount'] as int, nextRetryAt: _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String?), serverEvidenceId: row['serverEvidenceId'] as String?, serverImageUrl: row['serverImageUrl'] as String?, lastError: row['lastError'] as String?, unavailableReportedAt: _dateTimeEmptyConverter.decode(row['unavailableReportedAt'] as String?), completedAt: _dateTimeEmptyConverter.decode(row['completedAt'] as String?), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String)), + arguments: [serverOrderId, evidenceType, evidenceRole, sequenceNo]); + } + + @override + Future> getByOrderCode( + String androidOrderCode) async { + return _queryAdapter.queryList( + 'SELECT * FROM order_evidence_upload_task WHERE androidOrderCode = ?1 ORDER BY evidenceType, evidenceRole, sequenceNo', + mapper: (Map row) => OrderEvidenceUploadTaskEntity(id: row['id'] as int?, androidOrderCode: row['androidOrderCode'] as String?, serverOrderId: row['serverOrderId'] as String?, recognitionSessionId: row['recognitionSessionId'] as String, evidenceType: row['evidenceType'] as String, evidenceRole: row['evidenceRole'] as String, sequenceNo: row['sequenceNo'] as int, userId: row['userId'] as String, faceSampleId: row['faceSampleId'] as String?, referenceImageUrl: row['referenceImageUrl'] as String?, referenceImageVersion: row['referenceImageVersion'] as String?, localFilePath: row['localFilePath'] as String?, imageHash: row['imageHash'] as String?, imageSize: row['imageSize'] as int?, imageWidth: row['imageWidth'] as int?, imageHeight: row['imageHeight'] as int?, similarity: row['similarity'] as double?, similarityThreshold: row['similarityThreshold'] as double?, capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), appVersion: row['appVersion'] as String, status: row['status'] as String, attemptCount: row['attemptCount'] as int, nextRetryAt: _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String?), serverEvidenceId: row['serverEvidenceId'] as String?, serverImageUrl: row['serverImageUrl'] as String?, lastError: row['lastError'] as String?, unavailableReportedAt: _dateTimeEmptyConverter.decode(row['unavailableReportedAt'] as String?), completedAt: _dateTimeEmptyConverter.decode(row['completedAt'] as String?), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String)), + arguments: [androidOrderCode]); + } + + @override + Future> getByRecognitionSession( + String recognitionSessionId) async { + return _queryAdapter.queryList( + 'SELECT * FROM order_evidence_upload_task WHERE recognitionSessionId = ?1 ORDER BY evidenceRole, sequenceNo', + mapper: (Map row) => OrderEvidenceUploadTaskEntity(id: row['id'] as int?, androidOrderCode: row['androidOrderCode'] as String?, serverOrderId: row['serverOrderId'] as String?, recognitionSessionId: row['recognitionSessionId'] as String, evidenceType: row['evidenceType'] as String, evidenceRole: row['evidenceRole'] as String, sequenceNo: row['sequenceNo'] as int, userId: row['userId'] as String, faceSampleId: row['faceSampleId'] as String?, referenceImageUrl: row['referenceImageUrl'] as String?, referenceImageVersion: row['referenceImageVersion'] as String?, localFilePath: row['localFilePath'] as String?, imageHash: row['imageHash'] as String?, imageSize: row['imageSize'] as int?, imageWidth: row['imageWidth'] as int?, imageHeight: row['imageHeight'] as int?, similarity: row['similarity'] as double?, similarityThreshold: row['similarityThreshold'] as double?, capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), appVersion: row['appVersion'] as String, status: row['status'] as String, attemptCount: row['attemptCount'] as int, nextRetryAt: _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String?), serverEvidenceId: row['serverEvidenceId'] as String?, serverImageUrl: row['serverImageUrl'] as String?, lastError: row['lastError'] as String?, unavailableReportedAt: _dateTimeEmptyConverter.decode(row['unavailableReportedAt'] as String?), completedAt: _dateTimeEmptyConverter.decode(row['completedAt'] as String?), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String)), + arguments: [recognitionSessionId]); + } + + @override + Future> getDueTasks( + DateTime now, + int limit, + ) async { + return _queryAdapter.queryList( + 'SELECT * FROM order_evidence_upload_task WHERE status IN (\'WAITING_ORDER\', \'READY\', \'UPLOADING\', \'RETRY_WAIT\') AND (androidOrderCode IS NOT NULL OR serverOrderId IS NOT NULL) AND (nextRetryAt IS NULL OR nextRetryAt <= ?1) ORDER BY createTime ASC, id ASC LIMIT ?2', + mapper: (Map row) => OrderEvidenceUploadTaskEntity(id: row['id'] as int?, androidOrderCode: row['androidOrderCode'] as String?, serverOrderId: row['serverOrderId'] as String?, recognitionSessionId: row['recognitionSessionId'] as String, evidenceType: row['evidenceType'] as String, evidenceRole: row['evidenceRole'] as String, sequenceNo: row['sequenceNo'] as int, userId: row['userId'] as String, faceSampleId: row['faceSampleId'] as String?, referenceImageUrl: row['referenceImageUrl'] as String?, referenceImageVersion: row['referenceImageVersion'] as String?, localFilePath: row['localFilePath'] as String?, imageHash: row['imageHash'] as String?, imageSize: row['imageSize'] as int?, imageWidth: row['imageWidth'] as int?, imageHeight: row['imageHeight'] as int?, similarity: row['similarity'] as double?, similarityThreshold: row['similarityThreshold'] as double?, capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), appVersion: row['appVersion'] as String, status: row['status'] as String, attemptCount: row['attemptCount'] as int, nextRetryAt: _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String?), serverEvidenceId: row['serverEvidenceId'] as String?, serverImageUrl: row['serverImageUrl'] as String?, lastError: row['lastError'] as String?, unavailableReportedAt: _dateTimeEmptyConverter.decode(row['unavailableReportedAt'] as String?), completedAt: _dateTimeEmptyConverter.decode(row['completedAt'] as String?), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String)), + arguments: [_dateTimeConverter.encode(now), limit]); + } + + @override + Future> getUnavailableReportTasks( + DateTime now, + int limit, + ) async { + return _queryAdapter.queryList( + 'SELECT * FROM order_evidence_upload_task WHERE status IN (\'PERMANENT_FAILED\', \'EXPIRED\') AND (androidOrderCode IS NOT NULL OR serverOrderId IS NOT NULL) AND unavailableReportedAt IS NULL AND (nextRetryAt IS NULL OR nextRetryAt <= ?1) ORDER BY updateTime ASC, id ASC LIMIT ?2', + mapper: (Map row) => OrderEvidenceUploadTaskEntity(id: row['id'] as int?, androidOrderCode: row['androidOrderCode'] as String?, serverOrderId: row['serverOrderId'] as String?, recognitionSessionId: row['recognitionSessionId'] as String, evidenceType: row['evidenceType'] as String, evidenceRole: row['evidenceRole'] as String, sequenceNo: row['sequenceNo'] as int, userId: row['userId'] as String, faceSampleId: row['faceSampleId'] as String?, referenceImageUrl: row['referenceImageUrl'] as String?, referenceImageVersion: row['referenceImageVersion'] as String?, localFilePath: row['localFilePath'] as String?, imageHash: row['imageHash'] as String?, imageSize: row['imageSize'] as int?, imageWidth: row['imageWidth'] as int?, imageHeight: row['imageHeight'] as int?, similarity: row['similarity'] as double?, similarityThreshold: row['similarityThreshold'] as double?, capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), appVersion: row['appVersion'] as String, status: row['status'] as String, attemptCount: row['attemptCount'] as int, nextRetryAt: _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String?), serverEvidenceId: row['serverEvidenceId'] as String?, serverImageUrl: row['serverImageUrl'] as String?, lastError: row['lastError'] as String?, unavailableReportedAt: _dateTimeEmptyConverter.decode(row['unavailableReportedAt'] as String?), completedAt: _dateTimeEmptyConverter.decode(row['completedAt'] as String?), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String)), + arguments: [_dateTimeConverter.encode(now), limit]); + } + + @override + Future> getExpiredWaitingTasks( + DateTime now) async { + return _queryAdapter.queryList( + 'SELECT * FROM order_evidence_upload_task WHERE status = \'WAITING_ORDER\' AND expiresAt <= ?1 ORDER BY expiresAt ASC', + mapper: (Map row) => OrderEvidenceUploadTaskEntity(id: row['id'] as int?, androidOrderCode: row['androidOrderCode'] as String?, serverOrderId: row['serverOrderId'] as String?, recognitionSessionId: row['recognitionSessionId'] as String, evidenceType: row['evidenceType'] as String, evidenceRole: row['evidenceRole'] as String, sequenceNo: row['sequenceNo'] as int, userId: row['userId'] as String, faceSampleId: row['faceSampleId'] as String?, referenceImageUrl: row['referenceImageUrl'] as String?, referenceImageVersion: row['referenceImageVersion'] as String?, localFilePath: row['localFilePath'] as String?, imageHash: row['imageHash'] as String?, imageSize: row['imageSize'] as int?, imageWidth: row['imageWidth'] as int?, imageHeight: row['imageHeight'] as int?, similarity: row['similarity'] as double?, similarityThreshold: row['similarityThreshold'] as double?, capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), appVersion: row['appVersion'] as String, status: row['status'] as String, attemptCount: row['attemptCount'] as int, nextRetryAt: _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String?), serverEvidenceId: row['serverEvidenceId'] as String?, serverImageUrl: row['serverImageUrl'] as String?, lastError: row['lastError'] as String?, unavailableReportedAt: _dateTimeEmptyConverter.decode(row['unavailableReportedAt'] as String?), completedAt: _dateTimeEmptyConverter.decode(row['completedAt'] as String?), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String)), + arguments: [_dateTimeConverter.encode(now)]); + } + + @override + Future> getExpiredImageTasks( + DateTime now) async { + return _queryAdapter.queryList( + 'SELECT * FROM order_evidence_upload_task WHERE localFilePath IS NOT NULL AND status NOT IN (\'SUCCESS\', \'CANCELLED\') AND expiresAt <= ?1 ORDER BY expiresAt ASC', + mapper: (Map row) => OrderEvidenceUploadTaskEntity(id: row['id'] as int?, androidOrderCode: row['androidOrderCode'] as String?, serverOrderId: row['serverOrderId'] as String?, recognitionSessionId: row['recognitionSessionId'] as String, evidenceType: row['evidenceType'] as String, evidenceRole: row['evidenceRole'] as String, sequenceNo: row['sequenceNo'] as int, userId: row['userId'] as String, faceSampleId: row['faceSampleId'] as String?, referenceImageUrl: row['referenceImageUrl'] as String?, referenceImageVersion: row['referenceImageVersion'] as String?, localFilePath: row['localFilePath'] as String?, imageHash: row['imageHash'] as String?, imageSize: row['imageSize'] as int?, imageWidth: row['imageWidth'] as int?, imageHeight: row['imageHeight'] as int?, similarity: row['similarity'] as double?, similarityThreshold: row['similarityThreshold'] as double?, capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), appVersion: row['appVersion'] as String, status: row['status'] as String, attemptCount: row['attemptCount'] as int, nextRetryAt: _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String?), serverEvidenceId: row['serverEvidenceId'] as String?, serverImageUrl: row['serverImageUrl'] as String?, lastError: row['lastError'] as String?, unavailableReportedAt: _dateTimeEmptyConverter.decode(row['unavailableReportedAt'] as String?), completedAt: _dateTimeEmptyConverter.decode(row['completedAt'] as String?), createTime: _dateTimeConverter.decode(row['createTime'] as String), updateTime: _dateTimeConverter.decode(row['updateTime'] as String), expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String)), + arguments: [_dateTimeConverter.encode(now)]); + } + + @override + Future deleteSuccessfulBefore(DateTime before) async { + await _queryAdapter.queryNoReturn( + 'DELETE FROM order_evidence_upload_task WHERE status = \'SUCCESS\' AND completedAt IS NOT NULL AND completedAt < ?1', + arguments: [_dateTimeConverter.encode(before)]); + } + + @override + Future> getAll() async { + return _queryAdapter.queryList( + 'SELECT * FROM order_evidence_upload_task ORDER BY id ASC', + mapper: (Map row) => OrderEvidenceUploadTaskEntity( + id: row['id'] as int?, + androidOrderCode: row['androidOrderCode'] as String?, + serverOrderId: row['serverOrderId'] as String?, + recognitionSessionId: row['recognitionSessionId'] as String, + evidenceType: row['evidenceType'] as String, + evidenceRole: row['evidenceRole'] as String, + sequenceNo: row['sequenceNo'] as int, + userId: row['userId'] as String, + faceSampleId: row['faceSampleId'] as String?, + referenceImageUrl: row['referenceImageUrl'] as String?, + referenceImageVersion: row['referenceImageVersion'] as String?, + localFilePath: row['localFilePath'] as String?, + imageHash: row['imageHash'] as String?, + imageSize: row['imageSize'] as int?, + imageWidth: row['imageWidth'] as int?, + imageHeight: row['imageHeight'] as int?, + similarity: row['similarity'] as double?, + similarityThreshold: row['similarityThreshold'] as double?, + capturedAt: _dateTimeConverter.decode(row['capturedAt'] as String), + appVersion: row['appVersion'] as String, + status: row['status'] as String, + attemptCount: row['attemptCount'] as int, + nextRetryAt: + _dateTimeEmptyConverter.decode(row['nextRetryAt'] as String?), + serverEvidenceId: row['serverEvidenceId'] as String?, + serverImageUrl: row['serverImageUrl'] as String?, + lastError: row['lastError'] as String?, + unavailableReportedAt: _dateTimeEmptyConverter + .decode(row['unavailableReportedAt'] as String?), + completedAt: + _dateTimeEmptyConverter.decode(row['completedAt'] as String?), + createTime: _dateTimeConverter.decode(row['createTime'] as String), + updateTime: _dateTimeConverter.decode(row['updateTime'] as String), + expiresAt: _dateTimeConverter.decode(row['expiresAt'] as String))); + } + + @override + Future save(OrderEvidenceUploadTaskEntity entity) { + return _orderEvidenceUploadTaskEntityInsertionAdapter.insertAndReturnId( + entity, OnConflictStrategy.replace); + } + + @override + Future edit(OrderEvidenceUploadTaskEntity entity) async { + await _orderEvidenceUploadTaskEntityUpdateAdapter.update( + entity, OnConflictStrategy.abort); + } +} + // ignore_for_file: unused_element final _dateTimeConverter = DateTimeConverter(); final _dateTimeEmptyConverter = DateTimeEmptyConverter(); diff --git a/lib/common/services/database/database_migration.dart b/lib/common/services/database/database_migration.dart index 325009e..24b0d96 100644 --- a/lib/common/services/database/database_migration.dart +++ b/lib/common/services/database/database_migration.dart @@ -234,6 +234,195 @@ abstract class DatabaseMigration { '''); }); + + /// 从版本9到版本10的迁移(迁移自 biz_xfj_cloud):新增定时任务/任务日志/文件上传 + /// 记录/订单证据上传队列表,并为 face 表补充在库质量检测列。 + static Migration migration9To10 = Migration(9, 10, (database) async { + await database.execute(''' + CREATE TABLE IF NOT EXISTS scheduled_task ( + taskCode TEXT NOT NULL PRIMARY KEY, + enabled INTEGER NOT NULL, + cronExpression TEXT NOT NULL, + idleOnly INTEGER NOT NULL, + lastRunAt TEXT, + lastSuccessAt TEXT, + lastStatus TEXT NOT NULL, + lastMessage TEXT NOT NULL, + createTime TEXT NOT NULL, + updateTime TEXT NOT NULL + ) + '''); + await database.execute(''' + CREATE TABLE IF NOT EXISTS scheduled_task_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + taskCode TEXT NOT NULL, + taskName TEXT NOT NULL, + triggerType TEXT NOT NULL, + sourceType TEXT NOT NULL, + sourceName TEXT NOT NULL, + status TEXT NOT NULL, + message TEXT NOT NULL, + errorType TEXT, + errorMessage TEXT, + errorStack TEXT, + startedAt TEXT NOT NULL, + finishedAt TEXT, + durationMs INTEGER NOT NULL, + scannedCount INTEGER, + skippedCount INTEGER, + successCount INTEGER, + failureCount INTEGER + ) + '''); + await database.execute( + 'CREATE INDEX IF NOT EXISTS index_scheduled_task_log_taskCode_startedAt ' + 'ON scheduled_task_log(taskCode, startedAt)', + ); + await database.execute( + 'CREATE INDEX IF NOT EXISTS index_scheduled_task_log_sourceType_status_startedAt ' + 'ON scheduled_task_log(sourceType, status, startedAt)', + ); + await database.execute(''' + CREATE TABLE IF NOT EXISTS file_upload_record ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hardId TEXT NOT NULL, + bizType TEXT NOT NULL, + relativePath TEXT NOT NULL, + originalName TEXT NOT NULL, + sourceSize INTEGER NOT NULL, + sourceModifiedTime TEXT NOT NULL, + sanitizedHash TEXT NOT NULL, + sanitizedSize INTEGER NOT NULL, + status TEXT NOT NULL, + serverFileId TEXT, + attemptCount INTEGER NOT NULL, + lastAttemptAt TEXT, + uploadedAt TEXT, + lastError TEXT, + createTime TEXT NOT NULL, + updateTime TEXT NOT NULL + ) + '''); + await database.execute( + 'CREATE UNIQUE INDEX IF NOT EXISTS index_file_upload_record_hardId_bizType_relativePath ' + 'ON file_upload_record(hardId, bizType, relativePath)', + ); + await database.execute( + 'CREATE INDEX IF NOT EXISTS index_file_upload_record_bizType_status_updateTime ' + 'ON file_upload_record(bizType, status, updateTime)', + ); + await database.execute(''' + CREATE TABLE IF NOT EXISTS order_evidence_upload_task ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + androidOrderCode TEXT, + serverOrderId TEXT, + recognitionSessionId TEXT NOT NULL, + evidenceType TEXT NOT NULL, + evidenceRole TEXT NOT NULL, + sequenceNo INTEGER NOT NULL, + userId TEXT NOT NULL, + faceSampleId TEXT, + referenceImageUrl TEXT, + referenceImageVersion TEXT, + localFilePath TEXT, + imageHash TEXT, + imageSize INTEGER, + imageWidth INTEGER, + imageHeight INTEGER, + similarity REAL, + similarityThreshold REAL, + capturedAt TEXT NOT NULL, + appVersion TEXT NOT NULL, + status TEXT NOT NULL, + attemptCount INTEGER NOT NULL, + nextRetryAt TEXT, + serverEvidenceId TEXT, + serverImageUrl TEXT, + lastError TEXT, + unavailableReportedAt TEXT, + completedAt TEXT, + createTime TEXT NOT NULL, + updateTime TEXT NOT NULL, + expiresAt TEXT NOT NULL + ) + '''); + await database.execute( + 'CREATE UNIQUE INDEX IF NOT EXISTS index_order_evidence_upload_task_position ' + 'ON order_evidence_upload_task(androidOrderCode, evidenceType, evidenceRole, sequenceNo)', + ); + await database.execute( + 'CREATE INDEX IF NOT EXISTS index_order_evidence_upload_task_status_nextRetryAt_createTime ' + 'ON order_evidence_upload_task(status, nextRetryAt, createTime)', + ); + + // face 表补充在库质量检测列(迁移自 biz_xfj_cloud 版本11到12迁移)。 + final columns = await database.rawQuery('PRAGMA table_info(face)'); + final names = columns.map((column) => column['name']).toSet(); + if (!names.contains('qualityScore')) { + await database.execute('ALTER TABLE face ADD COLUMN qualityScore REAL'); + } + if (!names.contains('qualityStatus')) { + await database.execute( + "ALTER TABLE face ADD COLUMN qualityStatus TEXT NOT NULL DEFAULT 'pending'", + ); + } + if (!names.contains('qualityCheckedAt')) { + await database.execute( + 'ALTER TABLE face ADD COLUMN qualityCheckedAt TEXT', + ); + } + if (!names.contains('qualityErrorCode')) { + await database.execute( + 'ALTER TABLE face ADD COLUMN qualityErrorCode INTEGER', + ); + } + await database.execute( + "UPDATE face SET qualityStatus = 'pending' WHERE qualityStatus IS NULL OR qualityStatus NOT IN ('pending', 'detected', 'failed')", + ); + // 项目现有 DateTimeEmptyConverter 以空字符串表示业务空值;迁移旧记录时同步规范化, + // 避免 Floor 生成代码把 SQLite NULL 强转为 String 后读取失败。 + await database.execute( + "UPDATE face SET qualityCheckedAt = '' WHERE qualityCheckedAt IS NULL", + ); + await database.execute( + 'CREATE INDEX IF NOT EXISTS index_face_qualityStatus_qualityScore ON face(qualityStatus, qualityScore)', + ); + + // print_history 升级为幂等打印任务队列(迁移自 biz_xfj_cloud 版本12到13迁移)。 + final printColumns = await database.rawQuery('PRAGMA table_info(print_history)'); + final printColumnNames = printColumns.map((column) => column['name']).toSet(); + if (!printColumnNames.contains('printType')) { + await database.execute( + "ALTER TABLE print_history ADD COLUMN printType TEXT NOT NULL DEFAULT '1'"); + } + if (!printColumnNames.contains('printNo')) { + await database.execute( + 'ALTER TABLE print_history ADD COLUMN printNo INTEGER NOT NULL DEFAULT 1'); + } + if (!printColumnNames.contains('retryVersion')) { + await database.execute( + 'ALTER TABLE print_history ADD COLUMN retryVersion INTEGER NOT NULL DEFAULT 0'); + } + if (!printColumnNames.contains('attemptCount')) { + await database.execute( + 'ALTER TABLE print_history ADD COLUMN attemptCount INTEGER NOT NULL DEFAULT 0'); + } + if (!printColumnNames.contains('lastError')) { + await database.execute('ALTER TABLE print_history ADD COLUMN lastError TEXT'); + } + if (!printColumnNames.contains('resultPending')) { + await database.execute( + 'ALTER TABLE print_history ADD COLUMN resultPending INTEGER NOT NULL DEFAULT 0'); + } + await database.execute(''' + DELETE FROM print_history + WHERE id NOT IN (SELECT MAX(id) FROM print_history GROUP BY servicePrintId) + '''); + await database.execute( + 'CREATE UNIQUE INDEX IF NOT EXISTS index_print_history_servicePrintId ON print_history(servicePrintId)', + ); + }); + static List getDatabaseMigrations() { return [ migration1To2, @@ -244,6 +433,7 @@ abstract class DatabaseMigration { migration6To7, migration7To8, migration8To9, + migration9To10, ]; } } diff --git a/lib/common/services/database/entity/face_entity.dart b/lib/common/services/database/entity/face_entity.dart index 0cdfaa0..15404fc 100644 --- a/lib/common/services/database/entity/face_entity.dart +++ b/lib/common/services/database/entity/face_entity.dart @@ -41,6 +41,18 @@ class FaceEntity extends Equatable { /// 同步时间 DateTime? updateTime; + /// 最近一次在库图片质量分,范围为0到1;未检测或检测失败时为空。 + double? qualityScore; + + /// 质量检测状态,持久化值为pending、detected或failed。 + String qualityStatus; + + /// 最近一次质量检测完成时间;从未检测时为空。 + DateTime? qualityCheckedAt; + + /// 最近一次质量检测的虹软或插件错误码;成功时为空。 + int? qualityErrorCode; + FaceEntity({ this.id, required this.userId, @@ -53,6 +65,10 @@ class FaceEntity extends Equatable { this.faceModel, this.createTime, this.updateTime, + this.qualityScore, + this.qualityStatus = 'pending', + this.qualityCheckedAt, + this.qualityErrorCode, }); factory FaceEntity.fromJson(Map json) => diff --git a/lib/common/services/database/entity/face_entity.g.dart b/lib/common/services/database/entity/face_entity.g.dart index b9816fa..1900109 100644 --- a/lib/common/services/database/entity/face_entity.g.dart +++ b/lib/common/services/database/entity/face_entity.g.dart @@ -22,6 +22,12 @@ FaceEntity _$FaceEntityFromJson(Map json) => FaceEntity( updateTime: json['updateTime'] == null ? null : DateTime.parse(json['updateTime'] as String), + qualityScore: (json['qualityScore'] as num?)?.toDouble(), + qualityStatus: json['qualityStatus'] as String? ?? 'pending', + qualityCheckedAt: json['qualityCheckedAt'] == null + ? null + : DateTime.parse(json['qualityCheckedAt'] as String), + qualityErrorCode: (json['qualityErrorCode'] as num?)?.toInt(), ); Map _$FaceEntityToJson(FaceEntity instance) => @@ -37,4 +43,8 @@ Map _$FaceEntityToJson(FaceEntity instance) => 'faceModel': instance.faceModel, 'createTime': instance.createTime?.toIso8601String(), 'updateTime': instance.updateTime?.toIso8601String(), + 'qualityScore': instance.qualityScore, + 'qualityStatus': instance.qualityStatus, + 'qualityCheckedAt': instance.qualityCheckedAt?.toIso8601String(), + 'qualityErrorCode': instance.qualityErrorCode, }; diff --git a/lib/common/services/database/entity/file_upload_record_entity.dart b/lib/common/services/database/entity/file_upload_record_entity.dart new file mode 100644 index 0000000..2c8fe3f --- /dev/null +++ b/lib/common/services/database/entity/file_upload_record_entity.dart @@ -0,0 +1,97 @@ +import 'package:floor/floor.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'file_upload_record_entity.g.dart'; + +/// 本机文件上传记录。 +/// +/// 目前 [bizType] 使用 `terminal_log`,保留业务类型后可复用到数据库备份等文件任务。 +@JsonSerializable() +@Entity( + tableName: 'file_upload_record', + indices: [ + Index(value: ['hardId', 'bizType', 'relativePath'], unique: true), + Index(value: ['bizType', 'status', 'updateTime']), + ], +) +class FileUploadRecordEntity { + /// SQLite 自增主键;首次保存前为空。 + @PrimaryKey(autoGenerate: true) + final int? id; + + /// 终端绑定的设备 ID,用于隔离设备重新绑定前后的文件状态。 + final String hardId; + + /// 文件业务类型;终端日志固定使用 `terminal_log`。 + final String bizType; + + /// 文件在业务根目录下使用正斜杠的相对路径。 + final String relativePath; + + /// 原始文件名,不包含目录。 + final String originalName; + + /// 生成脱敏副本时读取的原文件快照大小,单位为字节。 + final int sourceSize; + + /// 生成脱敏副本时原文件的最后修改时间。 + final DateTime sourceModifiedTime; + + /// 脱敏后文件内容的 MD5 哈希;首次尝试尚未完成脱敏时可为空字符串。 + final String sanitizedHash; + + /// 脱敏后文件大小,单位为字节;尚未完成脱敏时为零。 + final int sanitizedSize; + + /// 最近一次上传尝试状态,保存 [FileUploadStatus.name]。 + final String status; + + /// 后端 hard_terminal_log_file 记录 ID;预检判定无需上传且本机无旧记录时可为空。 + final String? serverFileId; + + /// 对该路径发起过的上传尝试次数,预检前即递增。 + final int attemptCount; + + /// 最近一次开始上传尝试的本地时间;从未尝试时为空。 + final DateTime? lastAttemptAt; + + /// 最近一次被服务端确认成功或无需上传的本地时间。 + final DateTime? uploadedAt; + + /// 最近一次失败的截断错误摘要;成功记录为空。 + final String? lastError; + + /// 当前设备与业务类型下首次创建该路径记录的本地时间。 + final DateTime createTime; + + /// 状态、快照或服务端 ID 最近一次更新的本地时间。 + final DateTime updateTime; + + /// 创建一条文件上传状态记录。 + const FileUploadRecordEntity({ + this.id, + required this.hardId, + required this.bizType, + required this.relativePath, + required this.originalName, + required this.sourceSize, + required this.sourceModifiedTime, + required this.sanitizedHash, + required this.sanitizedSize, + required this.status, + this.serverFileId, + this.attemptCount = 0, + this.lastAttemptAt, + this.uploadedAt, + this.lastError, + required this.createTime, + required this.updateTime, + }); + + /// 从 JSON 数据恢复上传记录,仅供调试序列化使用。 + factory FileUploadRecordEntity.fromJson(Map json) => + _$FileUploadRecordEntityFromJson(json); + + /// 将上传记录转换为 JSON,仅供调试序列化使用。 + Map toJson() => _$FileUploadRecordEntityToJson(this); +} diff --git a/lib/common/services/database/entity/file_upload_record_entity.g.dart b/lib/common/services/database/entity/file_upload_record_entity.g.dart new file mode 100644 index 0000000..a3cd8af --- /dev/null +++ b/lib/common/services/database/entity/file_upload_record_entity.g.dart @@ -0,0 +1,55 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'file_upload_record_entity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FileUploadRecordEntity _$FileUploadRecordEntityFromJson( + Map json) => + FileUploadRecordEntity( + id: (json['id'] as num?)?.toInt(), + hardId: json['hardId'] as String, + bizType: json['bizType'] as String, + relativePath: json['relativePath'] as String, + originalName: json['originalName'] as String, + sourceSize: (json['sourceSize'] as num).toInt(), + sourceModifiedTime: DateTime.parse(json['sourceModifiedTime'] as String), + sanitizedHash: json['sanitizedHash'] as String, + sanitizedSize: (json['sanitizedSize'] as num).toInt(), + status: json['status'] as String, + serverFileId: json['serverFileId'] as String?, + attemptCount: (json['attemptCount'] as num?)?.toInt() ?? 0, + lastAttemptAt: json['lastAttemptAt'] == null + ? null + : DateTime.parse(json['lastAttemptAt'] as String), + uploadedAt: json['uploadedAt'] == null + ? null + : DateTime.parse(json['uploadedAt'] as String), + lastError: json['lastError'] as String?, + createTime: DateTime.parse(json['createTime'] as String), + updateTime: DateTime.parse(json['updateTime'] as String), + ); + +Map _$FileUploadRecordEntityToJson( + FileUploadRecordEntity instance) => + { + 'id': instance.id, + 'hardId': instance.hardId, + 'bizType': instance.bizType, + 'relativePath': instance.relativePath, + 'originalName': instance.originalName, + 'sourceSize': instance.sourceSize, + 'sourceModifiedTime': instance.sourceModifiedTime.toIso8601String(), + 'sanitizedHash': instance.sanitizedHash, + 'sanitizedSize': instance.sanitizedSize, + 'status': instance.status, + 'serverFileId': instance.serverFileId, + 'attemptCount': instance.attemptCount, + 'lastAttemptAt': instance.lastAttemptAt?.toIso8601String(), + 'uploadedAt': instance.uploadedAt?.toIso8601String(), + 'lastError': instance.lastError, + 'createTime': instance.createTime.toIso8601String(), + 'updateTime': instance.updateTime.toIso8601String(), + }; diff --git a/lib/common/services/database/entity/order_entity.g.dart b/lib/common/services/database/entity/order_entity.g.dart index 0b7af90..1bc26c8 100644 --- a/lib/common/services/database/entity/order_entity.g.dart +++ b/lib/common/services/database/entity/order_entity.g.dart @@ -82,6 +82,7 @@ const _$OrderPayTypeEnumMap = { OrderPayType.WALLET_DEFAULT: 'WALLET_DEFAULT', OrderPayType.WALLET_QR_CODE: 'WALLET_QR_CODE', OrderPayType.WALLET_QR_CODE_SCAN: 'WALLET_QR_CODE_SCAN', + OrderPayType.AGGREGATE_ORDER_PAY: 'AGGREGATE_ORDER_PAY', OrderPayType.WALLET_CARD: 'WALLET_CARD', OrderPayType.FACE: 'FACE', OrderPayType.STRIDE_DEPT_QR_CODE: 'STRIDE_DEPT_QR_CODE', diff --git a/lib/common/services/database/entity/order_evidence_upload_task_entity.dart b/lib/common/services/database/entity/order_evidence_upload_task_entity.dart new file mode 100644 index 0000000..772c560 --- /dev/null +++ b/lib/common/services/database/entity/order_evidence_upload_task_entity.dart @@ -0,0 +1,211 @@ +import 'package:floor/floor.dart'; + +/// 终端订单识别证据补传任务。 +/// +/// 本表是断网、崩溃和接口超时后的本地待办队列,不是最终证据库。服务端确认成功后立即删除 +/// [localFilePath] 对应的现场图片;失败图片最多保留 30 天,任务终态元数据按业务规则保留。 +@Entity( + tableName: 'order_evidence_upload_task', + indices: [ + Index( + value: [ + 'androidOrderCode', + 'evidenceType', + 'evidenceRole', + 'sequenceNo', + ], + unique: true, + ), + Index(value: ['status', 'nextRetryAt', 'createTime']), + ], +) +class OrderEvidenceUploadTaskEntity { + /// SQLite 自增主键,首次保存前为空。 + @PrimaryKey(autoGenerate: true) + final int? id; + + /// 终端生成的稳定订单号,用于响应超时后与服务端对账。 + final String? androidOrderCode; + + /// 对账成功后的服务端订单主键;尚未落单或请求结果不确定时为空。 + final String? serverOrderId; + + /// 产生现场图片的人脸识别业务会话编号,用于诊断但不作为服务端幂等键。 + final String recognitionSessionId; + + /// 证据类型,保存 `OrderEvidenceType.code`。 + final String evidenceType; + + /// 图片角色,保存 `OrderEvidenceRole.code`。 + final String evidenceRole; + + /// 同一订单、类型和角色下的稳定序号,从 0 开始。 + final int sequenceNo; + + /// 识别用户主键快照,来源于最终用于下单的人员。 + final String userId; + + /// 虹软样本编号快照;非人脸证据允许为空。 + final String? faceSampleId; + + /// 识别时本地人脸记录保存的后台参考图片地址,仅由服务端校验和复制。 + final String? referenceImageUrl; + + /// 识别时参考图片的版本摘要,当前使用本地人脸记录更新时间 ISO 字符串。 + final String? referenceImageVersion; + + /// 终端私有目录中的现场 JPEG 路径;参考图、已成功或图片到期任务为空。 + final String? localFilePath; + + /// 现场图片或已知参考图内容的 SHA-256;参考图未知内容时允许为空。 + final String? imageHash; + + /// 现场图片大小,单位字节;参考图未下载时允许为空。 + final int? imageSize; + + /// 现场图片宽度,单位像素;参考图未下载时允许为空。 + final int? imageWidth; + + /// 现场图片高度,单位像素;参考图未下载时允许为空。 + final int? imageHeight; + + /// 人脸比对相似度快照,范围 0 到 1;菜谱识别允许为空。 + final double? similarity; + + /// 人脸比对通过时使用的阈值快照,范围 0 到 1;菜谱识别允许为空。 + final double? similarityThreshold; + + /// 真正比对成功帧的终端采集时间。 + final DateTime capturedAt; + + /// 产生证据时的终端应用展示版本与内部版本组合。 + final String appVersion; + + /// 当前任务状态,保存 `OrderEvidenceTaskStatus.code`。 + final String status; + + /// 已发起的服务端对账、提交或上传尝试次数,用于计算退避间隔。 + final int attemptCount; + + /// 下一次允许自动处理的本地时间;立即可处理时为空。 + final DateTime? nextRetryAt; + + /// 服务端证据记录主键;尚未创建或永久缺失上报未返回时为空。 + final String? serverEvidenceId; + + /// 服务端最终证据图片地址;非 AVAILABLE 或参考复制失败时为空。 + final String? serverImageUrl; + + /// 最近一次失败的单行截断摘要,不包含 Token、原始帧、完整路径或图片内容。 + final String? lastError; + + /// 终端已向服务端成功报告 UNAVAILABLE 的时间;未报告或无订单时为空。 + final DateTime? unavailableReportedAt; + + /// 服务端确认 AVAILABLE 的完成时间;其他状态为空。 + final DateTime? completedAt; + + /// 任务首次创建时间,用于 24 小时未绑定订单和终态保留策略。 + final DateTime createTime; + + /// 状态、重试时间或服务端结果最近一次更新时间。 + final DateTime updateTime; + + /// 当前图片或未绑定任务的本地到期时间;绑定订单后延长为现场图片 30 天。 + final DateTime expiresAt; + + /// 创建一条可持久化的订单证据补传任务。 + const OrderEvidenceUploadTaskEntity({ + this.id, + this.androidOrderCode, + this.serverOrderId, + required this.recognitionSessionId, + required this.evidenceType, + required this.evidenceRole, + required this.sequenceNo, + required this.userId, + this.faceSampleId, + this.referenceImageUrl, + this.referenceImageVersion, + this.localFilePath, + this.imageHash, + this.imageSize, + this.imageWidth, + this.imageHeight, + this.similarity, + this.similarityThreshold, + required this.capturedAt, + required this.appVersion, + required this.status, + this.attemptCount = 0, + this.nextRetryAt, + this.serverEvidenceId, + this.serverImageUrl, + this.lastError, + this.unavailableReportedAt, + this.completedAt, + required this.createTime, + required this.updateTime, + required this.expiresAt, + }); + + /// 保留证据位置和识别快照,生成包含指定状态变化的新任务。 + OrderEvidenceUploadTaskEntity copyWith({ + String? androidOrderCode, + String? serverOrderId, + String? localFilePath, + bool clearLocalFilePath = false, + String? imageHash, + int? imageSize, + int? imageWidth, + int? imageHeight, + String? status, + int? attemptCount, + DateTime? nextRetryAt, + bool clearNextRetryAt = false, + String? serverEvidenceId, + String? serverImageUrl, + String? lastError, + bool clearLastError = false, + DateTime? unavailableReportedAt, + DateTime? completedAt, + DateTime? updateTime, + DateTime? expiresAt, + }) { + return OrderEvidenceUploadTaskEntity( + id: id, + androidOrderCode: androidOrderCode ?? this.androidOrderCode, + serverOrderId: serverOrderId ?? this.serverOrderId, + recognitionSessionId: recognitionSessionId, + evidenceType: evidenceType, + evidenceRole: evidenceRole, + sequenceNo: sequenceNo, + userId: userId, + faceSampleId: faceSampleId, + referenceImageUrl: referenceImageUrl, + referenceImageVersion: referenceImageVersion, + localFilePath: + clearLocalFilePath ? null : localFilePath ?? this.localFilePath, + imageHash: imageHash ?? this.imageHash, + imageSize: imageSize ?? this.imageSize, + imageWidth: imageWidth ?? this.imageWidth, + imageHeight: imageHeight ?? this.imageHeight, + similarity: similarity, + similarityThreshold: similarityThreshold, + capturedAt: capturedAt, + appVersion: appVersion, + status: status ?? this.status, + attemptCount: attemptCount ?? this.attemptCount, + nextRetryAt: clearNextRetryAt ? null : nextRetryAt ?? this.nextRetryAt, + serverEvidenceId: serverEvidenceId ?? this.serverEvidenceId, + serverImageUrl: serverImageUrl ?? this.serverImageUrl, + lastError: clearLastError ? null : lastError ?? this.lastError, + unavailableReportedAt: + unavailableReportedAt ?? this.unavailableReportedAt, + completedAt: completedAt ?? this.completedAt, + createTime: createTime, + updateTime: updateTime ?? this.updateTime, + expiresAt: expiresAt ?? this.expiresAt, + ); + } +} diff --git a/lib/common/services/database/entity/print_history_entity.dart b/lib/common/services/database/entity/print_history_entity.dart index dcc44c7..6a46e4b 100644 --- a/lib/common/services/database/entity/print_history_entity.dart +++ b/lib/common/services/database/entity/print_history_entity.dart @@ -6,7 +6,12 @@ import 'package:pj_supermarket_cloud/common/index.dart'; part 'print_history_entity.g.dart'; @JsonSerializable() -@Entity(tableName: "print_history") +@Entity( + tableName: "print_history", + indices: [ + Index(value: ['servicePrintId'], unique: true) + ], +) class PrintHistoryEntity { /// 自增ID @PrimaryKey(autoGenerate: true) @@ -24,6 +29,18 @@ class PrintHistoryEntity { /// 发票图片地址 String invoiceUrl; + /// 服务端凭证类型、打印序号与重试版本。 + String printType; + int printNo; + int retryVersion; + + /// 当前重试版本实际执行的 USB 尝试次数和最近错误。 + int attemptCount; + String? lastError; + + /// 打印结果尚未可靠提交到服务端时保持为 true,网络恢复后继续补报。 + bool resultPending; + /// 打印状态 PrintStatus status; @@ -42,6 +59,12 @@ class PrintHistoryEntity { required this.serviceOrderId, required this.servicePrintId, required this.invoiceUrl, + this.printType = '1', + this.printNo = 1, + this.retryVersion = 0, + this.attemptCount = 0, + this.lastError, + this.resultPending = false, required this.status, this.printTime, this.syncTime, diff --git a/lib/common/services/database/entity/print_history_entity.g.dart b/lib/common/services/database/entity/print_history_entity.g.dart index 6c5fac9..70b5777 100644 --- a/lib/common/services/database/entity/print_history_entity.g.dart +++ b/lib/common/services/database/entity/print_history_entity.g.dart @@ -13,6 +13,12 @@ PrintHistoryEntity _$PrintHistoryEntityFromJson(Map json) => serviceOrderId: json['serviceOrderId'] as String, servicePrintId: json['servicePrintId'] as String, invoiceUrl: json['invoiceUrl'] as String, + printType: json['printType'] as String? ?? '1', + printNo: (json['printNo'] as num?)?.toInt() ?? 1, + retryVersion: (json['retryVersion'] as num?)?.toInt() ?? 0, + attemptCount: (json['attemptCount'] as num?)?.toInt() ?? 0, + lastError: json['lastError'] as String?, + resultPending: json['resultPending'] as bool? ?? false, status: $enumDecode(_$PrintStatusEnumMap, json['status']), printTime: json['printTime'] as String?, syncTime: json['syncTime'] == null @@ -28,6 +34,12 @@ Map _$PrintHistoryEntityToJson(PrintHistoryEntity instance) => 'serviceOrderId': instance.serviceOrderId, 'servicePrintId': instance.servicePrintId, 'invoiceUrl': instance.invoiceUrl, + 'printType': instance.printType, + 'printNo': instance.printNo, + 'retryVersion': instance.retryVersion, + 'attemptCount': instance.attemptCount, + 'lastError': instance.lastError, + 'resultPending': instance.resultPending, 'status': _$PrintStatusEnumMap[instance.status]!, 'printTime': instance.printTime, 'syncTime': instance.syncTime?.toIso8601String(), diff --git a/lib/common/services/database/entity/scheduled_task_entity.dart b/lib/common/services/database/entity/scheduled_task_entity.dart new file mode 100644 index 0000000..56b205b --- /dev/null +++ b/lib/common/services/database/entity/scheduled_task_entity.dart @@ -0,0 +1,87 @@ +import 'package:floor/floor.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'scheduled_task_entity.g.dart'; + +/// 终端定时任务配置及最近一次执行快照。 +/// +/// [taskCode] 使用代码枚举名作为稳定主键,界面文案调整时不会产生重复任务。 +@JsonSerializable() +@Entity(tableName: 'scheduled_task') +class ScheduledTaskEntity { + /// 稳定任务编码,取 [ScheduledTaskType.name],作为表主键且不能随展示文案变化。 + @PrimaryKey() + final String taskCode; + + /// 是否启用 Cron 自动调度;关闭时仍可通过设置页手动触发。 + final bool enabled; + + /// cron 0.6.2 使用的六段表达式,字段顺序为秒、分、时、日、月、周。 + final String cronExpression; + + /// 自动任务是否必须等待连续 60 秒无人操作的完整空闲窗口。 + final bool idleOnly; + + /// 最近一次任务开始执行的本地时间;从未执行时为空。 + final DateTime? lastRunAt; + + /// 最近一次成功完成的本地时间;尚无成功记录时为空。 + final DateTime? lastSuccessAt; + + /// 最近一次运行状态,保存 [ScheduledTaskRunStatus.name]。 + final String lastStatus; + + /// 最近一次运行结果的简短中文摘要。 + final String lastMessage; + + /// 任务配置首次写入 SQLite 的本地时间。 + final DateTime createTime; + + /// 配置或执行快照最近一次更新的本地时间。 + final DateTime updateTime; + + /// 创建一条定时任务配置与最近执行快照。 + const ScheduledTaskEntity({ + required this.taskCode, + required this.enabled, + required this.cronExpression, + required this.idleOnly, + this.lastRunAt, + this.lastSuccessAt, + required this.lastStatus, + required this.lastMessage, + required this.createTime, + required this.updateTime, + }); + + /// 从 JSON 数据恢复实体,仅供兼容迁移和调试序列化使用。 + factory ScheduledTaskEntity.fromJson(Map json) => + _$ScheduledTaskEntityFromJson(json); + + /// 将实体转换为 JSON,仅供兼容迁移和调试序列化使用。 + Map toJson() => _$ScheduledTaskEntityToJson(this); + + /// 保留主键与创建时间,生成包含配置或最近执行结果变更的新实体。 + ScheduledTaskEntity copyWith({ + bool? enabled, + String? cronExpression, + bool? idleOnly, + DateTime? lastRunAt, + DateTime? lastSuccessAt, + String? lastStatus, + String? lastMessage, + DateTime? updateTime, + }) => + ScheduledTaskEntity( + taskCode: taskCode, + enabled: enabled ?? this.enabled, + cronExpression: cronExpression ?? this.cronExpression, + idleOnly: idleOnly ?? this.idleOnly, + lastRunAt: lastRunAt ?? this.lastRunAt, + lastSuccessAt: lastSuccessAt ?? this.lastSuccessAt, + lastStatus: lastStatus ?? this.lastStatus, + lastMessage: lastMessage ?? this.lastMessage, + createTime: createTime, + updateTime: updateTime ?? this.updateTime, + ); +} diff --git a/lib/common/services/database/entity/scheduled_task_entity.g.dart b/lib/common/services/database/entity/scheduled_task_entity.g.dart new file mode 100644 index 0000000..e3733b0 --- /dev/null +++ b/lib/common/services/database/entity/scheduled_task_entity.g.dart @@ -0,0 +1,40 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'scheduled_task_entity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ScheduledTaskEntity _$ScheduledTaskEntityFromJson(Map json) => + ScheduledTaskEntity( + taskCode: json['taskCode'] as String, + enabled: json['enabled'] as bool, + cronExpression: json['cronExpression'] as String, + idleOnly: json['idleOnly'] as bool, + lastRunAt: json['lastRunAt'] == null + ? null + : DateTime.parse(json['lastRunAt'] as String), + lastSuccessAt: json['lastSuccessAt'] == null + ? null + : DateTime.parse(json['lastSuccessAt'] as String), + lastStatus: json['lastStatus'] as String, + lastMessage: json['lastMessage'] as String, + createTime: DateTime.parse(json['createTime'] as String), + updateTime: DateTime.parse(json['updateTime'] as String), + ); + +Map _$ScheduledTaskEntityToJson( + ScheduledTaskEntity instance) => + { + 'taskCode': instance.taskCode, + 'enabled': instance.enabled, + 'cronExpression': instance.cronExpression, + 'idleOnly': instance.idleOnly, + 'lastRunAt': instance.lastRunAt?.toIso8601String(), + 'lastSuccessAt': instance.lastSuccessAt?.toIso8601String(), + 'lastStatus': instance.lastStatus, + 'lastMessage': instance.lastMessage, + 'createTime': instance.createTime.toIso8601String(), + 'updateTime': instance.updateTime.toIso8601String(), + }; diff --git a/lib/common/services/database/entity/scheduled_task_log_entity.dart b/lib/common/services/database/entity/scheduled_task_log_entity.dart new file mode 100644 index 0000000..33ce01f --- /dev/null +++ b/lib/common/services/database/entity/scheduled_task_log_entity.dart @@ -0,0 +1,135 @@ +import 'package:floor/floor.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'scheduled_task_log_entity.g.dart'; + +/// 定时任务的结构化执行日志。 +/// +/// 来源、触发方式、状态和统计值使用独立列,日志页面无需解析拼接文本即可筛选。 +@JsonSerializable() +@Entity( + tableName: 'scheduled_task_log', + indices: [ + Index(value: ['taskCode', 'startedAt']), + Index(value: ['sourceType', 'status', 'startedAt']), + ], +) +class ScheduledTaskLogEntity { + /// SQLite 自增主键;日志尚未插入数据库时为空。 + @PrimaryKey(autoGenerate: true) + final int? id; + + /// 任务稳定编码,关联 scheduled_task.taskCode,但不建立外键以兼容任务版本演进。 + final String taskCode; + + /// 执行发生时的任务展示名称快照。 + final String taskName; + + /// 触发来源,保存 [ScheduledTaskTriggerType.name]。 + final String triggerType; + + /// 主要依赖类型,保存 [ScheduledTaskSourceType.name]。 + final String sourceType; + + /// 执行发生时的依赖名称快照,例如 MQTT 消息服务或 HTTP 商品接口。 + final String sourceName; + + /// 本次执行状态,保存 [ScheduledTaskRunStatus.name]。 + final String status; + + /// 本次执行结果的简短中文摘要。 + final String message; + + /// 异常运行时的 Dart 异常类型;成功或跳过时为空。 + final String? errorType; + + /// 截断并脱敏后的异常摘要;成功或跳过时为空。 + final String? errorMessage; + + /// 截断后的异常堆栈,最多保存调度器允许的诊断长度。 + final String? errorStack; + + /// 任务开始执行的本地时间。 + final DateTime startedAt; + + /// 任务结束的本地时间;执行中日志为空。 + final DateTime? finishedAt; + + /// 从开始到结束的耗时,单位为毫秒;执行中默认为零。 + final int durationMs; + + /// 文件类任务扫描到的文件数量;不适用的任务为空。 + final int? scannedCount; + + /// 文件类任务因未变化或服务端已存在而跳过的数量;不适用时为空。 + final int? skippedCount; + + /// 文件类任务实际上传成功的数量;不适用时为空。 + final int? successCount; + + /// 文件类任务处理失败的数量;不适用时为空。 + final int? failureCount; + + /// 创建一条任务执行日志;开始记录尚未结束时不填写结束信息。 + const ScheduledTaskLogEntity({ + this.id, + required this.taskCode, + required this.taskName, + required this.triggerType, + required this.sourceType, + required this.sourceName, + required this.status, + required this.message, + this.errorType, + this.errorMessage, + this.errorStack, + required this.startedAt, + this.finishedAt, + this.durationMs = 0, + this.scannedCount, + this.skippedCount, + this.successCount, + this.failureCount, + }); + + /// 从 JSON 数据恢复任务日志,仅供调试序列化使用。 + factory ScheduledTaskLogEntity.fromJson(Map json) => + _$ScheduledTaskLogEntityFromJson(json); + + /// 将任务日志转换为 JSON,仅供调试序列化使用。 + Map toJson() => _$ScheduledTaskLogEntityToJson(this); + + /// 使用最终状态、错误和统计值生成同一条日志的完成快照。 + ScheduledTaskLogEntity finish({ + required String nextStatus, + required String nextMessage, + required DateTime at, + String? nextErrorType, + String? nextErrorMessage, + String? nextErrorStack, + int? scanned, + int? skipped, + int? succeeded, + int? failed, + }) => + ScheduledTaskLogEntity( + id: id, + taskCode: taskCode, + taskName: taskName, + triggerType: triggerType, + sourceType: sourceType, + sourceName: sourceName, + status: nextStatus, + message: nextMessage, + errorType: nextErrorType, + errorMessage: nextErrorMessage, + errorStack: nextErrorStack, + startedAt: startedAt, + finishedAt: at, + durationMs: at.difference(startedAt).inMilliseconds, + scannedCount: scanned, + skippedCount: skipped, + successCount: succeeded, + failureCount: failed, + ); +} diff --git a/lib/common/services/database/entity/scheduled_task_log_entity.g.dart b/lib/common/services/database/entity/scheduled_task_log_entity.g.dart new file mode 100644 index 0000000..641ffa2 --- /dev/null +++ b/lib/common/services/database/entity/scheduled_task_log_entity.g.dart @@ -0,0 +1,55 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'scheduled_task_log_entity.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ScheduledTaskLogEntity _$ScheduledTaskLogEntityFromJson( + Map json) => + ScheduledTaskLogEntity( + id: (json['id'] as num?)?.toInt(), + taskCode: json['taskCode'] as String, + taskName: json['taskName'] as String, + triggerType: json['triggerType'] as String, + sourceType: json['sourceType'] as String, + sourceName: json['sourceName'] as String, + status: json['status'] as String, + message: json['message'] as String, + errorType: json['errorType'] as String?, + errorMessage: json['errorMessage'] as String?, + errorStack: json['errorStack'] as String?, + startedAt: DateTime.parse(json['startedAt'] as String), + finishedAt: json['finishedAt'] == null + ? null + : DateTime.parse(json['finishedAt'] as String), + durationMs: (json['durationMs'] as num?)?.toInt() ?? 0, + scannedCount: (json['scannedCount'] as num?)?.toInt(), + skippedCount: (json['skippedCount'] as num?)?.toInt(), + successCount: (json['successCount'] as num?)?.toInt(), + failureCount: (json['failureCount'] as num?)?.toInt(), + ); + +Map _$ScheduledTaskLogEntityToJson( + ScheduledTaskLogEntity instance) => + { + 'id': instance.id, + 'taskCode': instance.taskCode, + 'taskName': instance.taskName, + 'triggerType': instance.triggerType, + 'sourceType': instance.sourceType, + 'sourceName': instance.sourceName, + 'status': instance.status, + 'message': instance.message, + 'errorType': instance.errorType, + 'errorMessage': instance.errorMessage, + 'errorStack': instance.errorStack, + 'startedAt': instance.startedAt.toIso8601String(), + 'finishedAt': instance.finishedAt?.toIso8601String(), + 'durationMs': instance.durationMs, + 'scannedCount': instance.scannedCount, + 'skippedCount': instance.skippedCount, + 'successCount': instance.successCount, + 'failureCount': instance.failureCount, + }; diff --git a/lib/common/services/face_recognition_session_service.dart b/lib/common/services/face_recognition_session_service.dart new file mode 100644 index 0000000..73bd17e --- /dev/null +++ b/lib/common/services/face_recognition_session_service.dart @@ -0,0 +1,473 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +class FaceRecognitionSessionHandle { + const FaceRecognitionSessionHandle({ + required this.sessionId, + required this.scene, + required this.events, + required this.result, + }); + + final String sessionId; + final FaceRecognitionScene scene; + final Stream events; + final Future result; +} + +/// 主屏唯一人脸会话入口,统一处理权限、原生状态、双屏动作和结果幂等。 +/// +/// Camera 与虹软热保持由 Android 负责;Dart 结束会话只关闭当前业务和 UI, +/// 不会让连续排队的下一位顾客重新打开摄像头。 +class FaceRecognitionSessionService extends GetxService { + static const Duration _viceStateTimeout = Duration(seconds: 2); + + final cameraOpen = false.obs; + final cameraStatus = FaceRecognitionSessionStatus.cold.obs; + + StreamSubscription? _cameraStateSubscription; + StreamSubscription? _nativeStateSubscription; + StreamSubscription? _faceSubscription; + StreamController? _eventController; + Completer? _resultCompleter; + String? _activeSessionId; + FaceRecognitionScene? _activeScene; + int _revision = 0; + int _nativeRevision = 0; + bool _resultLocked = false; + bool _nativeStarted = false; + bool _startFailureEmitted = false; + int? _payableAmount; + int? _totalAmount; + int? _discountAmount; + SubScreenAction? _lastViceAction; + String? _lastViceMessage; + + bool get isActive => _activeSessionId != null; + String? get activeSessionId => _activeSessionId; + + @override + void onInit() { + super.onInit(); + // 顶栏需要跨业务 Session 展示 Camera 热状态,因此常驻订阅不能跟随弹框关闭。 + _cameraStateSubscription = FaceDiscernPlugs.sessionStream.listen( + _trackCameraState, + onError: (_) => cameraOpen.value = false, + ); + unawaited(_refreshCameraState()); + } + + Future _refreshCameraState() async { + try { + _trackCameraState(await FaceDiscernPlugs.getRecognitionState()); + } catch (error) { + logger.w('|人脸识别|读取摄像头状态失败:$error'); + } + } + + void _trackCameraState(FaceRecognitionSessionEvent event) { + cameraOpen.value = event.warm; + cameraStatus.value = event.status; + } + + bool owns(String? sessionId) => + sessionId != null && sessionId == _activeSessionId; + + Future start({ + required FaceRecognitionScene scene, + int? payableAmount, + int? totalAmount, + int? discountAmount, + }) async { + if (_activeSessionId != null) { + throw const FaceRecognitionFailure( + code: 'SESSION_BUSY', + message: '已有进行中的人脸识别,请勿重复操作', + ); + } + final cameraPermission = await Permission.camera.request(); + if (!cameraPermission.isGranted) { + throw FaceRecognitionFailure( + code: 'CAMERA_PERMISSION_DENIED', + message: cameraPermission.isPermanentlyDenied + ? '相机权限已被永久拒绝,请在系统设置中开启' + : '人脸识别需要相机权限,请授权后重试', + ); + } + + final sessionId = DateTime.now().microsecondsSinceEpoch.toString(); + logger.i('|人脸识别|发起会话sessionId:$sessionId|scene:${scene.name}|'); + _activeSessionId = sessionId; + _activeScene = scene; + _revision = 0; + _nativeRevision = 0; + _resultLocked = false; + _nativeStarted = false; + _startFailureEmitted = false; + _payableAmount = payableAmount; + _totalAmount = totalAmount; + _discountAmount = discountAmount; + _lastViceAction = null; + _lastViceMessage = null; + _eventController = + StreamController.broadcast(); + _resultCompleter = Completer(); + _nativeStateSubscription = FaceDiscernPlugs.sessionStream.listen( + _handleNativeState, + onError: _handleNativeError, + ); + _faceSubscription = FaceDiscernPlugs.faceStream.listen(_handleFaceResult); + + final handle = FaceRecognitionSessionHandle( + sessionId: sessionId, + scene: scene, + events: _eventController!.stream, + result: _resultCompleter!.future, + ); + unawaited(_startNative( + sessionId: sessionId, + scene: scene, + payableAmount: payableAmount, + totalAmount: totalAmount, + discountAmount: discountAmount, + )); + return handle; + } + + Future _startNative({ + required String sessionId, + required FaceRecognitionScene scene, + int? payableAmount, + int? totalAmount, + int? discountAmount, + }) async { + try { + _startFailureEmitted = false; + // 副屏仅承担顾客提示,发送异常或插件不回包都不能阻断原生 Camera 主链路。 + unawaited(_sendViceState( + SubScreenAction.faceRecognitionStart, + message: '正在启动摄像头', + payableAmount: payableAmount, + totalAmount: totalAmount, + discountAmount: discountAmount, + )); + if (_activeSessionId != sessionId) return; + logger.i('|人脸识别|开始原生调用startRecognition|sessionId:$sessionId|'); + final initial = await FaceDiscernPlugs.startRecognition( + sessionId: sessionId, + scene: scene, + ); + logger.i( + '|人脸识别|原生调用startRecognition完成|sessionId:$sessionId|' + 'status:${initial.status.name}|revision:${initial.revision}|', + ); + if (_activeSessionId == sessionId) { + _nativeStarted = true; + if (initial.revision > _nativeRevision) { + _nativeRevision = initial.revision; + _eventController?.add(initial); + } + } + } on PlatformException catch (error) { + if (_activeSessionId != sessionId) return; + _nativeStarted = true; + _startFailureEmitted = true; + logger.e( + '|人脸识别|原生调用startRecognition失败|sessionId:$sessionId|' + 'code:${error.code}|message:${error.message}|', + ); + unawaited(_sendViceState( + SubScreenAction.faceRecognitionFailed, + message: error.message ?? '无法启动人脸识别,请检查设备后重试', + )); + _emitLocalFailure(FaceRecognitionFailure( + code: error.code, + message: error.message ?? '无法启动人脸识别,请检查设备后重试', + )); + } catch (error, stack) { + if (_activeSessionId != sessionId) return; + _nativeStarted = true; + _startFailureEmitted = true; + logger.e( + '|人脸识别|启动原生会话异常|sessionId:$sessionId|' + 'error:$error|stack:$stack|', + ); + _emitLocalFailure(const FaceRecognitionFailure( + code: 'RECOGNITION_START_FAILED', + message: '无法启动人脸识别,请检查设备后重试', + )); + } + } + + Future retry() async { + final sessionId = _activeSessionId; + if (sessionId == null) return; + _resultLocked = false; + _startFailureEmitted = false; + if (_nativeStarted) { + await FaceDiscernPlugs.retryRecognition(sessionId); + return; + } + await _startNative( + sessionId: sessionId, + scene: _activeScene!, + payableAmount: _payableAmount, + totalAmount: _totalAmount, + discountAmount: _discountAmount, + ); + } + + /// 取消当前业务人脸会话,并在释放原生资源前先让副屏退出人脸页。 + Future cancel({String message = '已取消人脸识别'}) async { + final sessionId = _activeSessionId; + if (sessionId == null) return; + // 主屏弹框会在本方法返回后立即关闭,必须先串行投递取消状态; + // 否则后续的预约查询消息可能先到,副屏仍会停留在人脸识别页。 + await _sendViceState( + SubScreenAction.faceRecognitionCancel, + message: message, + ); + logger.i('|人脸识别|开始原生调用cancelRecognition|sessionId:$sessionId|'); + await FaceDiscernPlugs.cancelRecognition(sessionId, message: message); + if (_resultCompleter?.isCompleted == false) { + _resultCompleter!.completeError( + FaceRecognitionFailure(code: 'CANCELLED', message: message), + ); + } + await _disposeSession(); + } + + Future finish() async { + final sessionId = _activeSessionId; + if (sessionId == null) return; + logger.i('|人脸识别|开始原生调用finishRecognition|sessionId:$sessionId|'); + await FaceDiscernPlugs.finishRecognition(sessionId); + await _disposeSession(); + } + + Future suspend() async { + final sessionId = _activeSessionId; + if (sessionId == null) return; + unawaited(_sendViceState( + SubScreenAction.faceRecognitionCancel, + message: '应用已进入后台,人脸识别已结束', + )); + logger.i('|人脸识别|开始原生调用suspendRecognition|sessionId:$sessionId|'); + await FaceDiscernPlugs.suspendRecognition( + reason: '应用已进入后台,人脸识别已结束', + ); + if (_resultCompleter?.isCompleted == false) { + _resultCompleter!.completeError(const FaceRecognitionFailure( + code: 'CANCELLED', + message: '应用已进入后台,人脸识别已结束', + )); + } + await _disposeSession(); + } + + void _handleNativeState(FaceRecognitionSessionEvent event) { + final sessionId = _activeSessionId; + if (sessionId == null || + event.sessionId != sessionId || + event.revision <= _nativeRevision) { + return; + } + _nativeRevision = event.revision; + // MethodChannel 失败会先于 EventChannel 中已排队的 initializing 事件返回。 + // 本地已经进入失败态后,必须丢弃这些旧进度,避免副屏从“需要重试”倒退到“识别中”。 + if (_startFailureEmitted && + (event.status == FaceRecognitionSessionStatus.initializing || + event.status == FaceRecognitionSessionStatus.detecting)) { + logger.w( + '|人脸识别|忽略启动失败后迟到的原生进度|sessionId:$sessionId|' + 'status:${event.status.name}|revision:${event.revision}|', + ); + return; + } + _eventController?.add(event); + switch (event.status) { + case FaceRecognitionSessionStatus.initializing: + case FaceRecognitionSessionStatus.detecting: + unawaited(_sendViceState( + SubScreenAction.faceRecognitionDetecting, + message: event.message.isEmpty ? '请正对摄像头' : event.message, + )); + break; + case FaceRecognitionSessionStatus.success: + unawaited(_sendViceState( + SubScreenAction.faceRecognitionSuccess, + message: '识别成功,正在读取信息', + )); + break; + case FaceRecognitionSessionStatus.failed: + unawaited(_sendViceState( + SubScreenAction.faceRecognitionFailed, + message: event.message.isEmpty ? '人脸识别失败,请重试' : event.message, + )); + final code = event.errorCode; + if (code == 'LOW_MEMORY' || code == 'SESSION_SUSPENDED') { + _completeWithFailure(FaceRecognitionFailure( + code: code!, + message: event.message.isEmpty ? '人脸识别已结束,请重新发起' : event.message, + )); + } + break; + case FaceRecognitionSessionStatus.cancelled: + unawaited(_sendViceState( + SubScreenAction.faceRecognitionCancel, + message: event.message.isEmpty ? '已取消人脸识别' : event.message, + )); + _completeWithFailure(FaceRecognitionFailure( + code: 'CANCELLED', + message: event.message.isEmpty ? '已取消人脸识别' : event.message, + )); + break; + case FaceRecognitionSessionStatus.cold: + case FaceRecognitionSessionStatus.warmIdle: + break; + } + } + + void _handleNativeError(Object error) { + const failure = FaceRecognitionFailure( + code: 'SESSION_STREAM_ERROR', + message: '人脸识别服务连接异常,请重试', + ); + _emitLocalFailure(failure); + _completeWithFailure(failure); + } + + void _emitLocalFailure(FaceRecognitionFailure failure) { + final sessionId = _activeSessionId; + final scene = _activeScene; + if (sessionId == null || scene == null) return; + _eventController?.add(FaceRecognitionSessionEvent( + sessionId: sessionId, + scene: scene, + status: FaceRecognitionSessionStatus.failed, + revision: ++_revision, + width: 1280, + height: 720, + rotation: 0, + mirror: true, + warm: false, + livenessEnabled: false, + keepMaxFace: false, + similarityThreshold: 0.8, + message: failure.message, + faceBoxes: const [], + errorCode: failure.code, + )); + } + + void _handleFaceResult(FaceStream? event) { + final activeSessionId = _activeSessionId; + if (event == null || activeSessionId == null || _resultLocked) return; + // EventChannel 可能在会话切换后仍投递上一帧;空会话或旧会话结果都不能绑定到下一笔订单。 + if (event.sessionId != activeSessionId) return; + final userId = event.userId?.trim(); + if (userId == null || userId.isEmpty) return; + _resultLocked = true; + _resultCompleter?.complete(event); + } + + void _completeWithFailure(FaceRecognitionFailure failure) { + if (_resultCompleter?.isCompleted == false) { + _resultCompleter!.completeError(failure); + } + } + + Future _sendViceState( + SubScreenAction action, { + required String message, + int? payableAmount, + int? totalAmount, + int? discountAmount, + }) async { + final sessionId = _activeSessionId; + final scene = _activeScene; + if (sessionId == null || scene == null || !AppSubScreenTool.isAvailable) { + return; + } + if (_lastViceAction == action && _lastViceMessage == message) return; + final revision = ++_revision; + // 版本在发出前即固定,避免插件不回包期间重复状态持续堆积 MethodChannel 请求。 + _lastViceAction = action; + _lastViceMessage = message; + logger.d( + '|人脸识别|发送副屏状态|sessionId:$sessionId|action:${action.code}|' + 'revision:$revision|', + ); + try { + final delivered = await AppRuntimeTool.sendViceMessage( + action.code, + params: { + 'sessionId': sessionId, + 'revision': revision, + 'scene': scene.name, + 'message': message, + if (payableAmount != null) 'payableAmount': payableAmount, + if (totalAmount != null) 'totalAmount': totalAmount, + if (discountAmount != null) 'discountAmount': discountAmount, + }, + ).timeout(_viceStateTimeout); + if (!delivered) { + logger.w( + '|人脸识别|副屏状态未投递,已按尽力通知处理|' + 'sessionId:$sessionId|action:${action.code}|revision:$revision|', + ); + return; + } + logger.d( + '|人脸识别|副屏状态发送完成|sessionId:$sessionId|' + 'action:${action.code}|revision:$revision|', + ); + } on TimeoutException { + logger.w( + '|人脸识别|副屏状态发送超时,已按尽力通知处理|' + 'sessionId:$sessionId|action:${action.code}|revision:$revision|' + 'timeout:${_viceStateTimeout.inMilliseconds}ms|', + ); + } catch (error) { + logger.w( + '|人脸识别|副屏状态同步失败|sessionId:$sessionId|' + 'action:${action.code}|revision:$revision|error:$error|', + ); + } + } + + Future _disposeSession() async { + await _nativeStateSubscription?.cancel(); + await _faceSubscription?.cancel(); + _nativeStateSubscription = null; + _faceSubscription = null; + await _eventController?.close(); + _eventController = null; + _resultCompleter = null; + _activeSessionId = null; + _activeScene = null; + _revision = 0; + _nativeRevision = 0; + _resultLocked = false; + _nativeStarted = false; + _startFailureEmitted = false; + _payableAmount = null; + _totalAmount = null; + _discountAmount = null; + _lastViceAction = null; + _lastViceMessage = null; + } + + @override + void onClose() { + unawaited(_cameraStateSubscription?.cancel()); + _cameraStateSubscription = null; + unawaited(suspend()); + super.onClose(); + } +} diff --git a/lib/common/services/index.dart b/lib/common/services/index.dart index 5f8f7a9..f9a542e 100644 --- a/lib/common/services/index.dart +++ b/lib/common/services/index.dart @@ -7,6 +7,10 @@ export './database/type_converter/type_converter.dart'; export './database/dao/broadcast_dao.dart'; export './database/dao/classify_dao.dart'; export './database/dao/face_dao.dart'; +export './database/dao/file_upload_record_dao.dart'; +export './database/dao/order_evidence_upload_task_dao.dart'; +export './database/dao/scheduled_task_dao.dart'; +export './database/dao/scheduled_task_log_dao.dart'; export './database/dao/order_hold_bill_dao.dart'; export './database/dao/order_dao.dart'; export './database/dao/print_history_dao.dart'; @@ -17,10 +21,15 @@ export './database/dao/supermarket_code_dao.dart'; export './database/dao/supermarket_sku_dao.dart'; export './database/dao/supermarket_spu_dao.dart'; export './database/dao/user_dao.dart'; +export './face_recognition_session_service.dart'; export './database/entity/broadcast_entity.dart'; export './database/entity/classify_entity.dart'; export './database/entity/face_entity.dart'; +export './database/entity/file_upload_record_entity.dart'; +export './database/entity/order_evidence_upload_task_entity.dart'; +export './database/entity/scheduled_task_entity.dart'; +export './database/entity/scheduled_task_log_entity.dart'; export './database/entity/order_entity.dart'; export './database/entity/order_hold_bill_entity.dart'; export './database/entity/print_history_entity.dart'; diff --git a/lib/common/style/index.dart b/lib/common/style/index.dart index fa8d106..009982c 100644 --- a/lib/common/style/index.dart +++ b/lib/common/style/index.dart @@ -1,3 +1,4 @@ library; -// export './xxxx.dart'; +export './xfj_design_tokens.dart'; + diff --git a/lib/common/style/xfj_design_tokens.dart b/lib/common/style/xfj_design_tokens.dart new file mode 100644 index 0000000..3d11a2d --- /dev/null +++ b/lib/common/style/xfj_design_tokens.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; + +/// 终端设置页面统一使用的语义视觉令牌。 +/// +/// 颜色取值全部派生自本项目 forui `FThemes.zinc.light` 主题(Tailwind zinc 色板), +/// 迁移自 biz_xfj_cloud 的设置页面只引用本令牌,不直接写色值,保证视觉跟随项目主题。 +abstract final class XfjDesignTokens { + /// 主操作色:zinc-900,与 FThemes.zinc.light.colorScheme.primary 一致。 + static const Color primary = Color(0xFF18181B); + + /// 主操作色的浅色衬底:zinc-100。 + static const Color primarySoft = Color(0xFFF4F4F5); + + /// 危险操作:red-600。 + static const Color danger = Color(0xFFDC2626); + + /// 警告提示:amber-600。 + static const Color warning = Color(0xFFD97706); + + /// 成功状态:green-600。 + static const Color success = Color(0xFF16A34A); + + /// 页面画布底色:zinc-50。 + static const Color canvas = Color(0xFFFAFAFA); + + /// 卡片表面色:纯白。 + static const Color surface = Color(0xFFFFFFFF); + + /// 主文字:zinc-900。 + static const Color ink = Color(0xFF18181B); + + /// 次要文字:zinc-500。 + static const Color muted = Color(0xFF71717A); + + /// 分隔线:zinc-200。 + static const Color line = Color(0xFFE4E4E7); + + /// 输入控件底色:zinc-100。 + static const Color control = Color(0xFFF4F4F5); + + /// 输入控件悬停底色:zinc-200。 + static const Color controlHover = Color(0xFFE4E4E7); + + static double _cardRadius = 12; + static double _controlRadius = 12; + static double _buttonSizeScale = 1; + + static double get cardRadius => _cardRadius; + static double get controlRadius => _controlRadius; + static double get buttonSizeScale => _buttonSizeScale; + + /// 同层级独立卡片只保留轻微分隔,避免横屏终端被无效留白挤占操作空间。 + static const double cardGap = 6; + + /// 工具栏控件共享同一高度,并保留 Android 终端所需的最小触控区域。 + static double get controlHeight => + (64 * _buttonSizeScale).clamp(60, 76).toDouble(); + + /// 自定义终端组件与 Forui 使用同一套运行时指标,避免只改一类组件造成视觉割裂。 + static void applyThemeMetrics({ + required double cardRadius, + required double controlRadius, + required double buttonSizeScale, + }) { + _cardRadius = cardRadius; + _controlRadius = controlRadius; + _buttonSizeScale = buttonSizeScale; + } + + static const List surfaceShadow = [ + BoxShadow( + color: Color(0x0A18181B), + blurRadius: 10, + offset: Offset(0, 2), + ), + ]; +} diff --git a/lib/common/utils/app/app_device_status_tool.dart b/lib/common/utils/app/app_device_status_tool.dart new file mode 100644 index 0000000..c05d835 --- /dev/null +++ b/lib/common/utils/app/app_device_status_tool.dart @@ -0,0 +1,407 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 单项终端设备探测函数,返回是否发现可用设备。 +typedef TerminalDeviceProbe = Future Function(); + +/// 汇总终端硬件状态,并将最近一次检查结果缓存到独立文件中。 +abstract final class AppDeviceStatusTool { + static const String _tag = '终端设备状态'; + static const String _statusKey = 'terminal_device_status'; + + static final CacheManager _statusCache = CacheManager( + Config( + 'terminal_device_status_cache', + stalePeriod: const Duration(days: 30), + maxNrOfCacheObjects: 2, + ), + ); + + static final Rx status = + const TerminalDeviceStatusSnapshot.empty().obs; + static final Rx progress = + const TerminalDeviceCheckProgress().obs; + + /// 打印机当前是否正在处理小票数据;该瞬时状态不写入缓存。 + static final RxBool printerWorking = false.obs; + + /// 电子秤当前是否被业务称重流程占用;后台可用性探测不计入工作中。 + static final RxBool steelyardWorking = false.obs; + + /// 读卡器当前是否正在执行读卡、写卡或诊断扫描;该瞬时状态不写入缓存。 + static final RxBool cardReaderWorking = false.obs; + + /// 顾客副屏 Presentation 当前是否已请求并保持显示;该瞬时状态不写入缓存。 + static final RxBool subScreenWorking = false.obs; + + static Future? _refreshFuture; + static Future _persistQueue = Future.value(); + static int _refreshGeneration = 0; + + static bool get isRefreshing => _refreshFuture != null; + + /// 启动时优先恢复缓存,硬件探测随后异步覆盖,避免顶栏长期显示写死状态。 + static Future loadCachedStatus() async { + try { + final fileInfo = await _statusCache.getFileFromCache(_statusKey); + if (fileInfo == null) return status.value; + final cached = TerminalDeviceStatusSnapshot.tryParse( + await fileInfo.file.readAsString(), + ); + status.value = cached ?? const TerminalDeviceStatusSnapshot.empty(); + _resetUnavailableWorkingState(status.value); + } catch (e, stack) { + status.value = const TerminalDeviceStatusSnapshot.empty(); + logger.w('|$_tag|读取缓存失败error:|$e|stack:|$stack|'); + } + return status.value; + } + + /// 同一时刻只执行一轮完整检查,防止设置页手动刷新与后台任务重复访问硬件。 + static Future refreshAll({ + TerminalDeviceProbe? printerProbe, + TerminalDeviceProbe? steelyardProbe, + TerminalDeviceProbe? cardReaderProbe, + TerminalDeviceProbe? subScreenProbe, + TerminalDeviceProbe? cameraProbe, + bool persist = true, + Duration deviceMinimumDuration = const Duration(seconds: 2), + Duration deviceTimeout = const Duration(seconds: 10), + }) { + final running = _refreshFuture; + if (running != null) return running; + + late final Future future; + final generation = ++_refreshGeneration; + future = _performRefresh( + printerProbe: printerProbe, + steelyardProbe: steelyardProbe, + cardReaderProbe: cardReaderProbe, + subScreenProbe: subScreenProbe, + cameraProbe: cameraProbe, + persist: persist, + deviceMinimumDuration: deviceMinimumDuration, + deviceTimeout: deviceTimeout, + generation: generation, + ).whenComplete(() { + if (identical(_refreshFuture, future)) _refreshFuture = null; + if (_refreshGeneration == generation) { + progress.value = const TerminalDeviceCheckProgress(); + } + }); + _refreshFuture = future; + return future; + } + + static Future _performRefresh({ + TerminalDeviceProbe? printerProbe, + TerminalDeviceProbe? steelyardProbe, + TerminalDeviceProbe? cardReaderProbe, + TerminalDeviceProbe? subScreenProbe, + TerminalDeviceProbe? cameraProbe, + required bool persist, + required Duration deviceMinimumDuration, + required Duration deviceTimeout, + required int generation, + }) async { + // Root 不可用是正常设备状态,检查与持久化失败只记录告警,不中断后续外围硬件探测。 + if (Get.isRegistered()) { + try { + final rootResult = await AppRuntimeTool.checkRootAccess(); + await AppSettingTool.setRootAccessResult(rootResult); + } catch (e, stack) { + logger.w('|$_tag|Root状态检查或持久化失败error:|$e|stack:|$stack|'); + } + } + final needsApp = printerProbe == null || + steelyardProbe == null || + cardReaderProbe == null; + final app = needsApp ? Get.find() : null; + final probes = <(TerminalDeviceType, TerminalDeviceProbe)>[ + ( + TerminalDeviceType.printer, + printerProbe ?? app!.printManage.checkAvailable, + ), + ( + TerminalDeviceType.steelyard, + steelyardProbe ?? app!.srialSteelyardManage.checkAvailable, + ), + ( + TerminalDeviceType.cardReader, + cardReaderProbe ?? app!.serialCardManage.checkAvailable, + ), + ( + TerminalDeviceType.subScreen, + subScreenProbe ?? + () => AppSubScreenTool.refreshAvailability(reportStatus: false), + ), + ( + TerminalDeviceType.camera, + cameraProbe ?? FaceDiscernPlugs.hasCamera, + ), + ]; + final results = []; + for (var index = 0; index < probes.length; index++) { + if (_refreshGeneration != generation) { + logger.i('|$_tag|用户取消设备状态检查'); + throw const TerminalDeviceCheckCancelled(); + } + final (device, probe) = probes[index]; + results.add( + await _checkDevice( + device, + probe, + minimumDuration: deviceMinimumDuration, + timeout: deviceTimeout, + completedCount: index, + generation: generation, + ), + ); + } + final next = TerminalDeviceStatusSnapshot( + printerAvailable: results[0], + steelyardAvailable: results[1], + cardReaderAvailable: results[2], + subScreenAvailable: results[3], + cameraAvailable: results[4], + updatedAt: DateTime.now(), + ); + status.value = next; + _resetUnavailableWorkingState(next); + if (persist) await _persist(next); + return next; + } + + static Future _checkDevice( + TerminalDeviceType device, + TerminalDeviceProbe probe, { + required Duration minimumDuration, + required Duration timeout, + required int completedCount, + required int generation, + }) async { + Timer? countdownTimer; + final stopwatch = Stopwatch()..start(); + final effectiveMinimumDuration = + minimumDuration > timeout ? timeout : minimumDuration; + var remainingSeconds = timeout.inSeconds; + var result = false; + var timedOut = false; + Object? probeError; + StackTrace? probeStack; + void reportProgress() { + if (_refreshGeneration != generation) return; + progress.value = TerminalDeviceCheckProgress( + running: true, + currentDevice: device, + remainingSeconds: remainingSeconds, + completedCount: completedCount, + ); + } + + reportProgress(); + countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (remainingSeconds > 0) remainingSeconds--; + reportProgress(); + }); + logger.i( + '|$_tag|正在检查${device.label},' + '最少${effectiveMinimumDuration.inSeconds}秒,最多${timeout.inSeconds}秒', + ); + try { + result = await probe().timeout(timeout); + } on TimeoutException { + timedOut = true; + } catch (e, stack) { + probeError = e; + probeStack = stack; + } finally { + // 插件秒回时仍保留最少展示时间,让用户能看清当前检查的设备与日志。 + final remainingMinimum = effectiveMinimumDuration - stopwatch.elapsed; + if (remainingMinimum > Duration.zero) { + await Future.delayed(remainingMinimum); + } + countdownTimer.cancel(); + stopwatch.stop(); + } + final elapsedMilliseconds = stopwatch.elapsedMilliseconds; + if (timedOut) { + logger.w( + '|$_tag|检查${device.label}超时,' + '耗时:$elapsedMilliseconds毫秒,按不可用处理', + ); + return false; + } + if (probeError != null) { + logger.w( + '|$_tag|检查${device.label}失败,' + '耗时:$elapsedMilliseconds毫秒,error:|$probeError|stack:|$probeStack|', + ); + return false; + } + logger.i( + '|$_tag|完成检查${device.label},' + '耗时:$elapsedMilliseconds毫秒,状态:${result ? '可用' : '不可用'}', + ); + return result; + } + + static void cancelRefresh() { + if (_refreshFuture == null) return; + _refreshGeneration++; + progress.value = const TerminalDeviceCheckProgress(); + logger.i('|$_tag|已请求取消本轮设备状态检查'); + } + + static void updatePrinter( + bool available, { + DateTime? checkedAt, + bool persist = true, + }) { + if (!available) updatePrinterWorking(false); + _update( + status.value.copyWith( + printerAvailable: available, + updatedAt: checkedAt ?? DateTime.now(), + ), + persist: persist, + ); + } + + static void updateSteelyard( + bool available, { + DateTime? checkedAt, + bool persist = true, + }) { + if (!available) updateSteelyardWorking(false); + _update( + status.value.copyWith( + steelyardAvailable: available, + updatedAt: checkedAt ?? DateTime.now(), + ), + persist: persist, + ); + } + + static void updateCardReader( + bool available, { + DateTime? checkedAt, + bool persist = true, + }) { + if (!available) updateCardReaderWorking(false); + _update( + status.value.copyWith( + cardReaderAvailable: available, + updatedAt: checkedAt ?? DateTime.now(), + ), + persist: persist, + ); + } + + static void updateSubScreen( + bool available, { + DateTime? checkedAt, + bool persist = true, + }) { + if (!available) updateSubScreenWorking(false); + _update( + status.value.copyWith( + subScreenAvailable: available, + updatedAt: checkedAt ?? DateTime.now(), + ), + persist: persist, + ); + } + + /// 更新摄像头是否存在;人脸识别是否工作由会话服务的实时状态单独决定。 + static void updateCamera( + bool available, { + DateTime? checkedAt, + bool persist = true, + }) { + _update( + status.value.copyWith( + cameraAvailable: available, + updatedAt: checkedAt ?? DateTime.now(), + ), + persist: persist, + ); + } + + /// 更新打印任务占用状态,重复值不触发顶栏重建。 + static void updatePrinterWorking(bool working) { + if (printerWorking.value != working) printerWorking.value = working; + } + + /// 更新业务称重占用状态,设备探测使用的临时 Reader 不调用此方法。 + static void updateSteelyardWorking(bool working) { + if (steelyardWorking.value != working) steelyardWorking.value = working; + } + + /// 更新读写卡流程占用状态,等待用户放卡期间仍属于工作中。 + static void updateCardReaderWorking(bool working) { + if (cardReaderWorking.value != working) cardReaderWorking.value = working; + } + + /// 更新副屏 Presentation 显示状态,设备存在但未显示时保持空闲。 + static void updateSubScreenWorking(bool working) { + if (subScreenWorking.value != working) subScreenWorking.value = working; + } + + /// 可用性变为 false 时同步清除瞬时活动,避免顶栏出现“无设备但工作中”的矛盾状态。 + static void _resetUnavailableWorkingState( + TerminalDeviceStatusSnapshot snapshot, + ) { + if (!snapshot.printerAvailable) updatePrinterWorking(false); + if (!snapshot.steelyardAvailable) updateSteelyardWorking(false); + if (!snapshot.cardReaderAvailable) updateCardReaderWorking(false); + if (!snapshot.subScreenAvailable) updateSubScreenWorking(false); + } + + static void _update( + TerminalDeviceStatusSnapshot next, { + required bool persist, + }) { + status.value = next; + if (persist) unawaited(_persist(next)); + } + + static Future _persist(TerminalDeviceStatusSnapshot snapshot) async { + // 实际硬件回调可能连续到达,串行写入可避免较旧快照后完成并覆盖新状态。 + _persistQueue = _persistQueue.then((_) async { + try { + await _statusCache.putFile( + _statusKey, + Uint8List.fromList(utf8.encode(snapshot.toCacheString())), + fileExtension: 'json', + ); + } catch (e, stack) { + logger.w('|$_tag|写入缓存失败error:|$e|stack:|$stack|'); + } + }); + await _persistQueue; + } + + /// 测试只重置内存状态,不触碰真实设备缓存。 + static void resetForTest() { + _refreshFuture = null; + _refreshGeneration = 0; + _persistQueue = Future.value(); + status.value = const TerminalDeviceStatusSnapshot.empty(); + progress.value = const TerminalDeviceCheckProgress(); + printerWorking.value = false; + steelyardWorking.value = false; + cardReaderWorking.value = false; + subScreenWorking.value = false; + } +} + +class TerminalDeviceCheckCancelled implements Exception { + const TerminalDeviceCheckCancelled(); +} diff --git a/lib/common/utils/app/app_face_tool.dart b/lib/common/utils/app/app_face_tool.dart index 12ca8fa..1d88a91 100644 --- a/lib/common/utils/app/app_face_tool.dart +++ b/lib/common/utils/app/app_face_tool.dart @@ -1,63 +1,148 @@ -import 'dart:convert'; - -import 'package:common_utils/common_utils.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +/// 统一封装人脸 SDK 激活、状态查询、本地人脸库注册和删除操作。 +/// +/// 所有激活参数均从终端系统设置读取,调用方不得传入或记录密钥明文。 abstract class AppFaceTool { + /// 人脸工具日志标签,不记录激活参数和人脸特征等敏感内容。 static const String _tag = "app 人脸工具"; - // 跳转到人脸识别页面 + /// 打开原生人脸识别页面;手动硬件模式或插件拒绝打开时返回 false。 static Future jumpFacePage() async { + if (AppRuntimeTool.useManualHardwareInput) return false; final isJump = await FaceDiscernPlugs.jumpFaceDiscernPage(); if (!isJump) return false; + // 收银机副屏在识别页停留两秒后自动退回商品页。 Future.delayed(const Duration(seconds: 2), () { SubScreenPlugin.doubleScreenCancel(); }); return true; } - // 检查人脸激活参数 - static bool _validateSysFaceParams(SysSetting sysSetting) { - if (ObjectUtil.isEmpty(sysSetting.faceAppId)) { - return false; - } - if (ObjectUtil.isEmpty(sysSetting.faceSdkKey)) { - return false; - } - if (ObjectUtil.isEmpty(sysSetting.faceActiveKey)) { - return false; - } - return true; + /// 检查后端下发的人脸激活参数是否全部具有非空有效值。 + static bool validateSysFaceParams(SysSetting sysSetting) { + return missingSysFaceParams(sysSetting).isEmpty; } - // 批量注册人脸 + /// 返回缺失的人脸参数字段名,供 Load 页给出可排查但不泄露密钥的提示。 + static List missingSysFaceParams(SysSetting sysSetting) { + final missing = []; + if ((sysSetting.faceAppId ?? '').trim().isEmpty) missing.add('APP_ID'); + if ((sysSetting.faceSdkKey ?? '').trim().isEmpty) missing.add('SDK_KEY'); + if ((sysSetting.faceActiveKey ?? '').trim().isEmpty) { + missing.add('ACTIVE_KEY'); + } + return List.unmodifiable(missing); + } + + /// 在 Floor 完成建库后激活虹软 SDK,并将本地数据库人脸完整重建到内存库。 + /// + /// 任意插件、权限或样本异常都转换为警告结果,调用方可以继续完成终端启动。 + static Future prepareForStartup() async { + if (AppRuntimeTool.useManualHardwareInput) { + return const FaceStartupPreparationResult( + status: FaceStartupPreparationStatus.skipped, + message: '当前为手动硬件输入模式,已跳过真实人脸设备准备', + total: 0, + successCount: 0, + failedCount: 0, + failedSampleIds: [], + missingParameters: [], + ); + } + + final setting = await AppSettingTool.getSysSetting(); + final missing = missingSysFaceParams(setting); + if (missing.isNotEmpty) { + return FaceStartupPreparationResult( + status: FaceStartupPreparationStatus.warning, + message: '缺少 ${missing.join('、')},请先同步设备配置', + total: 0, + successCount: 0, + failedCount: 0, + failedSampleIds: const [], + missingParameters: missing, + ); + } + + FaceActivationResult activation; + try { + activation = await activateSdk(); + } catch (error, stack) { + logger.w('|$_tag|启动阶段激活人脸插件异常error:|$error|stack:|$stack|'); + return const FaceStartupPreparationResult( + status: FaceStartupPreparationStatus.warning, + message: '人脸插件暂时无法激活,可进入设置后重试', + total: 0, + successCount: 0, + failedCount: 0, + failedSampleIds: [], + missingParameters: [], + ); + } + if (!activation.success) { + return FaceStartupPreparationResult( + status: FaceStartupPreparationStatus.warning, + message: activation.message, + total: 0, + successCount: 0, + failedCount: 0, + failedSampleIds: const [], + missingParameters: const [], + activationCode: activation.code, + ); + } + + try { + final registration = await FaceDiscernPlugs.rebuildFaceLibrary(); + final status = registration.success + ? FaceStartupPreparationStatus.success + : FaceStartupPreparationStatus.warning; + return FaceStartupPreparationResult( + status: status, + message: registration.message, + total: registration.total, + successCount: registration.successCount, + failedCount: registration.failedCount, + failedSampleIds: List.unmodifiable(registration.failedSampleIds), + missingParameters: const [], + activationCode: activation.code, + ); + } catch (error, stack) { + logger.w('|$_tag|启动阶段重建人脸库异常error:|$error|stack:|$stack|'); + return FaceStartupPreparationResult( + status: FaceStartupPreparationStatus.warning, + message: '人脸插件已激活,但本地人脸库注册失败,可在设置中重新注册', + total: 0, + successCount: 0, + failedCount: 0, + failedSampleIds: const [], + missingParameters: const [], + activationCode: activation.code, + ); + } + } + + /// 按需将本地人脸记录注册到 SDK 内存库。 + /// + /// [userId] 为空时处理完整人脸库,[forceRegister] 为 true 时忽略当前内存库数量并强制重建。 + /// 返回值表示调用完成后内存库中是否至少存在一条可识别人脸。 static Future faceRegister({ String? userId, bool forceRegister = false, }) async { + if (AppRuntimeTool.useManualHardwareInput) return true; var faceCount = await FaceDiscernPlugs.getFaceCount(); if (faceCount == 0 || forceRegister) { - final list = await FaceDiscernPlugs.registerFaceByDatabase( + final result = await FaceDiscernPlugs.rebuildFaceLibrary( userId: userId, ); - if (!ObjectUtil.isEmptyList(list)) { - final app = Get.find(); - final faceDao = app.dataBase.getFaceDao; - logger.i("|$_tag|批量注册人脸,错误的人脸:${json.encode(list)}"); - for (final v in list) { - final sampleId = v.toString(); - final userFace = await faceDao.findBySampleId(sampleId); - if (ObjectUtil.isNotEmpty(userFace)) { - if (ObjectUtil.isNotEmpty(userFace?.path)) { - await SyncFaceTool.delFaceByStorage(filePath: userFace!.path!); - } - await faceDao.delById(userFace!.id!); - await AppFaceTool.removeFace(sampleId: sampleId); - } - } + if (result.failedSampleIds.isNotEmpty) { + logger.w('|$_tag|批量注册人脸失败数量:${result.failedSampleIds.length}'); } } faceCount = await FaceDiscernPlugs.getFaceCount(); @@ -65,33 +150,171 @@ abstract class AppFaceTool { return true; } - // 是否激活人脸sdk - static Future isActiveSdk() async { - final isNetwork = AppNetworkTool.isAutoNetwork(); - if (!isNetwork) return true; - var faceConfig = await FaceDiscernPlugs.getConfig(); - if (faceConfig == null || faceConfig.isActive == false) { - final sysSetting = await AppSettingTool.getSysSetting(); - final valid = AppFaceTool._validateSysFaceParams(sysSetting); - if (!valid) return false; - final isActive = await FaceDiscernPlugs.active( - appId: sysSetting.faceAppId!, - sdkKey: sysSetting.faceSdkKey!, - activeKey: sysSetting.faceActiveKey!, - ); - if (isActive) { - faceConfig = await FaceDiscernPlugs.getConfig(); - } + /// 将 SDK 识别结果解析为当前后端仍有效的人员和钱包信息。 + /// + /// 正常情况直接使用本地样本或 SDK 返回的人员编号查询;编号失效时,仅允许 + /// 通过现有全量人脸同步刷新同一个 [FaceStream.sampleId] 的归属后重试。 + /// 禁止按本地饭卡号替换为另一人员,避免旧人脸样本误绑定到卡片的新持有人。 + static Future resolveRecognizedUser({ + required FaceStream recognition, + required SysType sysType, + }) async { + final app = Get.find(); + final faceDao = app.dataBase.getFaceDao; + final candidateUserIds = {}; + final sampleId = recognition.sampleId?.toString(); + if (sampleId != null && sampleId.isNotEmpty) { + final localFace = await faceDao.findBySampleId(sampleId); + _addCandidateUserId(candidateUserIds, localFace?.userId); } - if (faceConfig == null) { - return false; + _addCandidateUserId(candidateUserIds, recognition.userId); + + if (!AppNetworkTool.isAutoNetwork()) { + return _findCachedRecognizedUser(candidateUserIds); } - return faceConfig.isActive; + + final currentUser = await _findRemoteRecognizedUser( + candidateUserIds, + sysType, + ); + if (currentUser != null || sampleId == null || sampleId.isEmpty) { + return currentUser; + } + + // 识别会话已经由调用方结束,此处才允许进入统一人脸队列,避免并发操作引擎。 + // 已知人员只回查该用户;没有可靠用户标识时才对账本部门,禁止恢复流程拉取全租户人脸。 + logger.w('|$_tag|识别样本对应人员已失效,开始回查当前范围人脸并刷新样本归属|'); + final scheduler = SyncCheckTool.instance; + await scheduler.trigger( + ScheduledTaskType.departmentFaceSync, + faceUserIds: candidateUserIds.isEmpty ? null : candidateUserIds, + triggerType: ScheduledTaskTriggerType.system, + ); + if (scheduler.runningTasks.contains(ScheduledTaskType.departmentFaceSync) || + scheduler.pendingTasks.contains(ScheduledTaskType.departmentFaceSync)) { + // 已有同步占用引擎时不读取旧归属继续支付;队列完成后由下一次识别使用新数据。 + return null; + } + final refreshedFace = await faceDao.findBySampleId(sampleId); + final refreshedUserId = refreshedFace?.userId.trim(); + if (refreshedUserId == null || + refreshedUserId.isEmpty || + candidateUserIds.contains(refreshedUserId)) { + return null; + } + candidateUserIds.add(refreshedUserId); + return _findRemoteRecognizedUser({refreshedUserId}, sysType); } - // 删除人脸 + /// 将非空人员编号加入有序候选集合,保持本地样本优先于原生回包。 + static void _addCandidateUserId(Set target, String? userId) { + final value = userId?.trim(); + if (value == null || value.isEmpty) return; + target.add(value); + } + + /// 离线时从本地人员缓存解析识别结果;没有缓存则返回空并交由业务提示同步。 + static Future _findCachedRecognizedUser( + Set candidateUserIds, + ) async { + for (final userId in candidateUserIds) { + final user = await SyncUserTool.storageUserByUserId(userId); + if (user != null) return SyncUserTool.parsePayUser(user: user); + } + return null; + } + + /// 依次使用现有人员查询接口校验候选编号,并缓存首个仍有效的人员。 + static Future _findRemoteRecognizedUser( + Set candidateUserIds, + SysType sysType, + ) async { + for (final userId in candidateUserIds) { + final user = await HardApi.getUserInfoById({ + 'id': userId, + 'type': sysType.name, + }); + if (user == null || (user.userId ?? '').trim().isEmpty) continue; + await SyncUserTool.storageUser(user: user); + return user; + } + return null; + } + + /// 查询当前设备的人脸 SDK 是否已经激活并可继续初始化。 + static Future isActiveSdk() async { + if (AppRuntimeTool.useManualHardwareInput) return true; + final status = await getStatus(); + return status?.activated == true; + } + + /// 获取激活、引擎和人脸库一致性状态。 + /// + /// 参数不完整时返回未就绪状态;手动硬件输入模式不连接原生通道并返回 null。 + static Future getStatus() async { + final setting = await AppSettingTool.getSysSetting(); + // 参数完整性来自本地已同步配置,不依赖人脸硬件或原生通道;虚拟机和手动输入模式也应明确展示“等待同步配置”。 + if (!validateSysFaceParams(setting)) { + return const FacePluginStatus( + activated: false, + activationCode: -1, + parametersComplete: false, + engineReady: false, + engineCode: -1, + databaseCount: 0, + memoryCount: 0, + registrationInProgress: false, + libraryConsistent: false, + ); + } + if (AppRuntimeTool.useManualHardwareInput) return null; + return FaceDiscernPlugs.getFaceStatus( + appId: setting.faceAppId ?? '', + sdkKey: setting.faceSdkKey ?? '', + activeKey: setting.faceActiveKey ?? '', + ); + } + + /// 激活参数只来自后端同步后的系统设置,页面不接收也不展示密钥明文。 + static Future activateSdk() async { + final setting = await AppSettingTool.getSysSetting(); + if (!validateSysFaceParams(setting)) { + return const FaceActivationResult( + success: false, + code: -1, + message: '人脸插件参数不完整,请先同步设备配置', + requiresPermission: false, + canOpenSettings: false, + ); + } + + final permission = await Permission.phone.status; + if (!permission.isGranted) { + final requested = await Permission.phone.request(); + if (!requested.isGranted) { + return FaceActivationResult( + success: false, + code: -1, + message: requested.isPermanentlyDenied + ? '电话状态权限已被永久拒绝,请在系统设置中开启' + : '激活需要电话状态权限,请授权后重试', + requiresPermission: true, + canOpenSettings: requested.isPermanentlyDenied, + ); + } + } + + return FaceDiscernPlugs.activateFaceSdk( + appId: setting.faceAppId!.trim(), + sdkKey: setting.faceSdkKey!.trim(), + activeKey: setting.faceActiveKey!.trim(), + ); + } + + /// 从 SDK 内存库删除指定样本;样本编号为空、SDK 未激活或插件失败时返回 false。 static Future removeFace({String? sampleId}) async { if (sampleId == null) return false; + if (AppRuntimeTool.useManualHardwareInput) return true; final isActive = await AppFaceTool.isActiveSdk(); if (!isActive) return false; return await FaceDiscernPlugs.removeFace(sampleId: sampleId); diff --git a/lib/common/utils/app/app_network_tool.dart b/lib/common/utils/app/app_network_tool.dart index 45eaf33..8c721c5 100644 --- a/lib/common/utils/app/app_network_tool.dart +++ b/lib/common/utils/app/app_network_tool.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:common_utils/common_utils.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:get/get.dart'; @@ -12,17 +13,25 @@ import 'package:pj_supermarket_cloud/common/index.dart'; abstract class AppNetworkTool { static const _tag = "网络工具类"; + /// 文件中心对外读取 OSS 对象的统一网关路径。 + static const _ossFilePath = '/admin/sys-file/oss/file'; + static StreamSubscription>? subscription; static bool isNetworkOssImage(String imagePath) { - return imagePath.contains("?fileName="); + final uri = Uri.tryParse(imagePath.trim()); + return uri?.queryParameters.containsKey('fileName') == true; } // 是否是网络图片 static bool isNetworkImage(String imagePath) { if (isNetworkOssImage(imagePath)) return true; - final networkImagePattern = RegExp(r'^(http|https)://'); - return networkImagePattern.hasMatch(imagePath); + final value = imagePath.trim(); + if (value.startsWith('//')) return true; + final uri = Uri.tryParse(value); + return uri != null && + (uri.scheme == 'http' || uri.scheme == 'https') && + uri.host.isNotEmpty; } // 手动判断有没有要网络 todo 不建议使用 @@ -37,6 +46,22 @@ abstract class AppNetworkTool { return bool; } + /// 仅用于状态展示的轻量探测,不输出周期性日志。 + static Future isConnectedNow() async { + final list = await Connectivity().checkConnectivity(); + return list.any((item) => item != ConnectivityResult.none); + } + + /// 原生通道无法识别模拟器网络时,使用 connectivity_plus 补充连接类型。 + static Future getNetworkTypeNow() async { + final list = await Connectivity().checkConnectivity(); + if (list.contains(ConnectivityResult.wifi)) return 'wifi'; + if (list.contains(ConnectivityResult.ethernet)) return 'ethernet'; + if (list.contains(ConnectivityResult.mobile)) return 'mobile'; + if (list.any((item) => item != ConnectivityResult.none)) return 'unknown'; + return 'offline'; + } + // 自动判断是否有网络 static bool isAutoNetwork() { final app = Get.find(); @@ -55,33 +80,51 @@ abstract class AppNetworkTool { // 自动监听网络变化 static void listenNetwork() async { - subscription = Connectivity() - .onConnectivityChanged - .listen((List list) { - final bool = !list.contains(ConnectivityResult.none); - final app = Get.find(); - if (bool != app.isNetwork.value) { - app.isNetwork.value = bool; - EasyLoading.dismiss(); - AppMqTool.mqRefresh(); - logger.w("|$_tag|监听网络变化,当前网络已经${bool ? '连接' : '断开'}"); - if (bool) SyncOrderTool.syncOfflineOrder(); - } - }); + final initial = await Connectivity().checkConnectivity(); + _applyConnectivity(initial); + subscription = + Connectivity().onConnectivityChanged.listen(_applyConnectivity); + } + + static void _applyConnectivity(List list) { + final connected = list.any((item) => item != ConnectivityResult.none); + final app = Get.find(); + if (connected == app.isNetwork.value) return; + app.isNetwork.value = connected; + EasyLoading.dismiss(); + logger.w("|$_tag|监听网络变化,当前网络已经${connected ? '连接' : '断开'}"); + if (connected) unawaited(_restoreNetworkServices()); + } + + /// 网络恢复后的消息连接和离线订单同步允许失败,不能产生未处理异常中断应用。 + static Future _restoreNetworkServices() async { + final userSetting = await AppSettingTool.getUserSetting(); + // 登录页只需要网络状态;退出后禁止网络变化重新连接 MQTT 或调用打印待办接口。 + if (ObjectUtil.isEmpty(userSetting.accessToken)) return; + try { + await AppMqTool.mqRefresh(); + await SyncPrintTool.pullPendingTasks(); + await SyncPrintTool.doCheckSync(); + } catch (e, stack) { + logger.e('|$_tag|网络恢复后连接消息服务失败error:|$e|stack:|$stack|'); + } + try { + await SyncOrderTool.syncOfflineOrder(); + } catch (e, stack) { + logger.e('|$_tag|网络恢复后同步离线订单失败error:|$e|stack:|$stack|'); + } } // 关闭监听网络 static void closeListenNetwork() { subscription?.cancel(); + subscription = null; } /// 从网络中获取图片 static Future loadImageFromNetwork(String url) async { try { - String networkImage = url; - if (AppNetworkTool.isNetworkOssImage(networkImage)) { - networkImage = "${Constants.ossUrl}$networkImage"; - } + final networkImage = serverImageUrl(url); final response = await http.get(Uri.parse(networkImage)); if (response.statusCode == 200) { final Uint8List imageData = response.bodyBytes; @@ -100,10 +143,7 @@ abstract class AppNetworkTool { // 从网络中下载图片 static Future downloadImage(String url) async { - String networkImage = url; - if (AppNetworkTool.isNetworkOssImage(networkImage)) { - networkImage = "${Constants.ossUrl}$networkImage"; - } + final networkImage = serverImageUrl(url); try { final client = http.Client(); try { @@ -126,7 +166,39 @@ abstract class AppNetworkTool { } } + /// 将服务端图片字段统一转换为图片组件可直接加载的绝对地址。 + /// + /// 完整 HTTP(S) 地址保持不变;文件中心相对地址拼到 [Constants.ossUrl] + /// 配置的部署前缀后;只有 OSS 对象名时,通过标准文件读取接口访问。 + static String serverImageUrl(String src) { + final value = src.trim(); + if (value.isEmpty) return value; + + final sourceUri = Uri.tryParse(value); + if (sourceUri != null && + (sourceUri.scheme == 'http' || sourceUri.scheme == 'https') && + sourceUri.host.isNotEmpty) { + return value; + } + + final baseUri = Uri.parse(Constants.ossUrl); + final origin = baseUri.origin; + final base = Constants.ossUrl.replaceFirst(RegExp(r'/+$'), ''); + if (value.startsWith('//')) return '${baseUri.scheme}:$value'; + if (value.startsWith('/api/')) return '$origin$value'; + if (value.startsWith('api/')) return '$origin/$value'; + if (value.startsWith('/admin/')) return '$base$value'; + if (value.startsWith('admin/')) return '$base/$value'; + + final endpoint = Uri.parse('$base$_ossFilePath'); + if (value.startsWith('?')) { + return endpoint.replace(query: value.substring(1)).toString(); + } + return endpoint.replace(queryParameters: {'fileName': value}).toString(); + } + + /// 兼容既有调用名称,所有 OSS 图片地址统一委托给全局解析方法。 static String ossImage(String src) { - return "${Constants.ossUrl}$src"; + return serverImageUrl(src); } } diff --git a/lib/common/utils/app/app_runtime_tool.dart b/lib/common/utils/app/app_runtime_tool.dart new file mode 100644 index 0000000..be1104e --- /dev/null +++ b/lib/common/utils/app/app_runtime_tool.dart @@ -0,0 +1,602 @@ +import 'package:flutter/services.dart'; + +import 'package:pj_supermarket_cloud/common/models/manage/root_access_models.dart'; + +/// 当前 Android 设备的运行环境。 +class AppRuntimeEnvironment { + /// 是否命中 Android 虚拟机特征;通道不可用时按真机降级,避免阻塞应用启动。 + final bool isEmulator; + + /// Android DisplayManager 当前识别的显示屏数量,最少按单屏处理。 + final int displayCount; + + /// 当前 Android 系统发行版本,例如 `11`;原生字段不可用时为空字符串。 + final String androidVersion; + + /// 当前 Android API Level,例如 Android 11 对应 30;原生字段不可用时为 0。 + final int androidSdkInt; + + /// 当前进程实际安装 APK 的展示版本,不使用后台待更新版本代替。 + final String apkVersionName; + + /// 当前进程实际安装 APK 的内部版本码;原生字段不可用时为 0。 + final int apkVersionCode; + + /// 创建一份由 Android 原生运行时采集的不可变环境快照。 + const AppRuntimeEnvironment({ + required this.isEmulator, + required this.displayCount, + required this.androidVersion, + required this.androidSdkInt, + required this.apkVersionName, + required this.apkVersionCode, + }); + + /// 从 MethodChannel 返回值解析环境快照,缺失版本字段使用可安全展示的空值。 + factory AppRuntimeEnvironment.fromMap(Map? data) { + return AppRuntimeEnvironment( + isEmulator: data?['isEmulator'] == true, + displayCount: (data?['displayCount'] as num?)?.toInt() ?? 1, + androidVersion: data?['androidVersion']?.toString() ?? '', + androidSdkInt: (data?['androidSdkInt'] as num?)?.toInt() ?? 0, + apkVersionName: data?['apkVersionName']?.toString() ?? '', + apkVersionCode: (data?['apkVersionCode'] as num?)?.toInt() ?? 0, + ); + } +} + +/// Android 专用设备模式的实时状态。 +class AppKioskStatus { + final bool deviceAdminActive; + final bool deviceOwner; + final bool available; + final bool active; + final bool lockTaskPermitted; + final bool autoEnterEnabled; + final bool bootAutoStartSupported; + + const AppKioskStatus({ + required this.deviceAdminActive, + required this.deviceOwner, + required this.available, + required this.active, + required this.lockTaskPermitted, + required this.autoEnterEnabled, + required this.bootAutoStartSupported, + }); + + const AppKioskStatus.unavailable() + : deviceAdminActive = false, + deviceOwner = false, + available = false, + active = false, + lockTaskPermitted = false, + autoEnterEnabled = false, + bootAutoStartSupported = false; + + factory AppKioskStatus.fromMap(Map? data) { + return AppKioskStatus( + deviceAdminActive: data?['deviceAdminActive'] == true, + deviceOwner: data?['deviceOwner'] == true, + available: data?['available'] == true, + active: data?['active'] == true, + lockTaskPermitted: data?['lockTaskPermitted'] == true, + autoEnterEnabled: data?['autoEnterEnabled'] == true, + bootAutoStartSupported: data?['bootAutoStartSupported'] == true, + ); + } + + bool get canExitToAndroidHome => available && active; + + String get statusLabel { + if (!available) return '暂不可实现'; + if (active && !autoEnterEnabled) return 'Kiosk 运行中 · 自动开启已关闭'; + if (active) return '可以实现 · Kiosk 运行中'; + if (!autoEnterEnabled) return '可以实现 · 自动开启已关闭'; + if (lockTaskPermitted) return '可以实现 · 正在进入'; + return '可以实现 · 等待策略生效'; + } + + String get description { + if (available && active && !autoEnterEnabled) { + return '当前仍在 Kiosk;管理员退出后,再次打开或回到 App 将不再自动锁定。'; + } + if (available && !autoEnterEnabled) { + return '设备具备 Device Owner 权限;开启右侧配置后,每次打开或回到 App 都会自动进入 Kiosk。'; + } + if (available) { + return '已配置 Device Owner;每次打开或回到 App 都会自动进入 Kiosk,并隐藏主页、最近任务和系统栏。'; + } + if (deviceAdminActive) { + return '当前仅激活了设备管理员,仍不能可靠禁用主页和最近任务;需要配置为 Device Owner。'; + } + return '未配置 Device Owner,普通全屏不能保证无法退出;需在恢复出厂或未初始化的设备上部署。'; + } +} + +/// Root 执行 DPM 后返回的结构化 Kiosk 启用结果。 +class AppKioskProvisionResult { + /// 创建一份包含稳定错误编码、可操作说明和最新 Kiosk 状态的结果。 + const AppKioskProvisionResult({ + required this.success, + required this.code, + required this.message, + required this.status, + }); + + /// DPM 是否成功配置当前应用为 Device Owner 并进入 Kiosk。 + final bool success; + + /// 原生层稳定错误编码,用于区分账户、Owner、用户和厂商限制。 + final String code; + + /// 面向终端管理员展示的中文处理建议。 + final String message; + + /// 命令执行完成后重新读取的 Kiosk 实时状态。 + final AppKioskStatus status; + + /// 从原生结构化返回值恢复结果,通道字段缺失时使用安全失败值。 + factory AppKioskProvisionResult.fromMap(Map? data) { + return AppKioskProvisionResult( + success: data?['success'] == true, + code: data?['code']?.toString() ?? 'UNKNOWN', + message: data?['message']?.toString() ?? 'Kiosk 启用失败,请检查设备策略', + status: AppKioskStatus.fromMap(data?['status'] as Map?), + ); + } +} + +/// Android APK 校验或 Root 安装命令的结构化结果。 +class AppApkOperationResult { + /// 创建一份供自动更新流程判断的原生操作结果。 + const AppApkOperationResult({ + required this.success, + required this.code, + required this.message, + this.versionCode, + }); + + /// APK 校验通过或安装命令成功执行时为 true。 + final bool success; + + /// 原生稳定结果编码,例如 SIGNATURE_MISMATCH 或 INSTALL_STARTED。 + final String code; + + /// 面向任务日志或人工操作展示的中文摘要。 + final String message; + + /// 原生 APK 实际版本码;返回数据不含版本时为空。 + final int? versionCode; + + /// 从 MethodChannel 返回映射创建操作结果。 + factory AppApkOperationResult.fromMap(Map? data) { + return AppApkOperationResult( + success: data?['valid'] == true, + code: data?['code']?.toString() ?? 'UNKNOWN', + message: data?['message']?.toString() ?? '原生 APK 操作失败', + versionCode: (data?['versionCode'] as num?)?.toInt(), + ); + } +} + +/// 应用启动时消费的待更新恢复结果。 +class AppUpdateRecoveryResult { + /// 创建一份待更新确认结果。 + const AppUpdateRecoveryResult({ + required this.pending, + required this.success, + required this.message, + }); + + /// 原生层是否存在上次安装前持久化的待更新记录。 + final bool pending; + + /// 当前安装版本是否达到待更新目标版本码。 + final bool success; + + /// 启动确认结果摘要,用于完成任务记录。 + final String message; + + /// 从 MethodChannel 返回映射创建恢复结果。 + factory AppUpdateRecoveryResult.fromMap(Map? data) { + return AppUpdateRecoveryResult( + pending: data?['pending'] == true, + success: data?['success'] == true, + message: data?['message']?.toString() ?? '没有待确认的自动更新', + ); + } +} + +class AppNetworkSnapshot { + final bool connected; + final String type; + final int receivedBytes; + final int transmittedBytes; + + const AppNetworkSnapshot({ + required this.connected, + required this.type, + required this.receivedBytes, + required this.transmittedBytes, + }); + + factory AppNetworkSnapshot.fromMap(Map? data) { + return AppNetworkSnapshot( + connected: data?['connected'] == true, + type: data?['type']?.toString() ?? 'offline', + receivedBytes: (data?['rxBytes'] as num?)?.toInt() ?? 0, + transmittedBytes: (data?['txBytes'] as num?)?.toInt() ?? 0, + ); + } +} + +/// Android 进程与设备资源快照。 +/// +/// CPU 使用率需要比较两次 [processCpuTimeMillis],原生层只返回累计值,避免在平台通道内 +/// 持有采样定时器;内存和存储同时保留总量,便于顶栏给出百分比并在 Tooltip 展示明细。 +class AppPerformanceSnapshot { + /// Android 系统自本次开机后的运行时长,单位为秒。 + final int systemUptimeSeconds; + + /// 当前应用进程自启动后的运行时长,单位为秒。 + final int appUptimeSeconds; + + /// Android 进程从启动至采样时累计消耗的 CPU 时间,单位为毫秒。 + final int processCpuTimeMillis; + + /// Android Runtime 报告的逻辑处理器数量,最小按 1 处理。 + final int logicalProcessorCount; + + /// 系统物理内存总量,单位为字节;平台数据不可用时为 0。 + final int systemMemoryTotalBytes; + + /// 系统当前可用物理内存,单位为字节;平台数据不可用时为 0。 + final int systemMemoryAvailableBytes; + + /// 当前应用进程的 PSS 内存占用,单位为字节。 + final int appMemoryBytes; + + /// 应用文件目录所在存储卷的总容量,单位为字节;平台数据不可用时为 0。 + final int storageTotalBytes; + + /// 应用文件目录所在存储卷的可用容量,单位为字节;平台数据不可用时为 0。 + final int storageAvailableBytes; + + /// 创建一份原生平台已经完成单位换算的资源快照。 + const AppPerformanceSnapshot({ + required this.systemUptimeSeconds, + required this.appUptimeSeconds, + required this.processCpuTimeMillis, + required this.logicalProcessorCount, + required this.systemMemoryTotalBytes, + required this.systemMemoryAvailableBytes, + required this.appMemoryBytes, + required this.storageTotalBytes, + required this.storageAvailableBytes, + }); + + /// 创建平台通道不可用时的空快照,百分比 Getter 将稳定返回 0。 + const AppPerformanceSnapshot.empty() + : systemUptimeSeconds = 0, + appUptimeSeconds = 0, + processCpuTimeMillis = 0, + logicalProcessorCount = 1, + systemMemoryTotalBytes = 0, + systemMemoryAvailableBytes = 0, + appMemoryBytes = 0, + storageTotalBytes = 0, + storageAvailableBytes = 0; + + /// 从 Android MethodChannel 返回值解析资源快照,缺失或类型异常字段按 0 处理。 + factory AppPerformanceSnapshot.fromMap(Map? data) { + int value(String key) => (data?[key] as num?)?.toInt() ?? 0; + return AppPerformanceSnapshot( + systemUptimeSeconds: value('systemUptimeSeconds'), + appUptimeSeconds: value('appUptimeSeconds'), + processCpuTimeMillis: value('processCpuTimeMs'), + logicalProcessorCount: + value('logicalProcessorCount').clamp(1, 256).toInt(), + systemMemoryTotalBytes: value('memoryTotalBytes'), + systemMemoryAvailableBytes: value('memoryAvailableBytes'), + appMemoryBytes: value('appMemoryBytes'), + storageTotalBytes: value('storageTotalBytes'), + storageAvailableBytes: value('storageAvailableBytes'), + ); + } + + /// 系统内存已使用比例,范围为 0 到 1;总量未知时返回 0。 + double get memoryUsageRatio => _usedRatio( + total: systemMemoryTotalBytes, + available: systemMemoryAvailableBytes, + ); + + /// 应用所在存储卷已使用比例,范围为 0 到 1;总量未知时返回 0。 + double get storageUsageRatio => _usedRatio( + total: storageTotalBytes, + available: storageAvailableBytes, + ); + + /// 根据总量和可用量计算受限于 0 到 1 的占用比例,容忍原生瞬时采样误差。 + static double _usedRatio({required int total, required int available}) { + if (total <= 0) return 0; + return ((total - available) / total).clamp(0, 1).toDouble(); + } +} + +/// 统一管理虚拟机识别和硬件手动输入模式,避免各业务页面自行猜测设备能力。 +abstract class AppRuntimeTool { + static const MethodChannel _channel = MethodChannel( + 'biz_xfj/runtime_environment', + ); + + static AppRuntimeEnvironment _environment = const AppRuntimeEnvironment( + isEmulator: false, + displayCount: 1, + androidVersion: '', + androidSdkInt: 0, + apkVersionName: '', + apkVersionCode: 0, + ); + static bool _initialized = false; + static bool _manualHardwareInput = false; + static AppKioskStatus _kioskStatus = const AppKioskStatus.unavailable(); + + /// 请求 Android 终端执行短震动;通道不可用或平台不支持时回退到 Flutter 触觉反馈。 + static Future vibrate({int durationMs = 60}) async { + final safeDuration = durationMs.clamp(20, 200).toInt(); + try { + final handled = await _channel.invokeMethod( + 'vibrate', + {'durationMs': safeDuration}, + ); + if (handled == true) return; + } on MissingPluginException { + // 非 Android 平台没有运行时通道,继续使用 Flutter 提供的系统触觉能力。 + } on PlatformException { + // 厂商设备禁用震动或原生调用异常时,回退触觉不能影响弹框关闭控制。 + } + await HapticFeedback.mediumImpact(); + } + + static bool get isEmulator => _environment.isEmulator; + + static int get displayCount => _environment.displayCount; + + /// 返回设置页使用的 Android 发行版本与 API Level,通道不可用时明确显示未知。 + static String get androidVersionLabel { + final version = _environment.androidVersion.trim(); + final sdk = _environment.androidSdkInt; + if (version.isNotEmpty && sdk > 0) return 'Android $version(API $sdk)'; + if (version.isNotEmpty) return 'Android $version'; + if (sdk > 0) return 'Android API $sdk'; + return '未知'; + } + + /// 返回当前实际安装 APK 的展示版本与 Build,字段缺失时仍保留已知部分。 + static String get apkVersionLabel { + final versionName = _environment.apkVersionName.trim(); + final versionCode = _environment.apkVersionCode; + if (versionName.isNotEmpty && versionCode > 0) { + return '$versionName(Build $versionCode)'; + } + if (versionName.isNotEmpty) return versionName; + if (versionCode > 0) return 'Build $versionCode'; + return '未知'; + } + + /// 虚拟机始终使用手动输入;真机可在设置中主动开启手动输入模式。 + static bool get useManualHardwareInput => isEmulator || _manualHardwareInput; + + static bool get canUsePhysicalHardware => !useManualHardwareInput; + + static bool get hasMultipleDisplays => displayCount >= 2; + + static AppKioskStatus get kioskStatus => _kioskStatus; + + static Future initializePlatform() async { + if (_initialized) return; + try { + final data = await _channel.invokeMapMethod( + 'getRuntimeEnvironment', + ); + _environment = AppRuntimeEnvironment.fromMap(data); + _kioskStatus = await refreshKioskStatus(); + } on PlatformException { + // 原生通道异常时按单屏处理,避免错误地启动副屏。 + _environment = const AppRuntimeEnvironment( + isEmulator: false, + displayCount: 1, + androidVersion: '', + androidSdkInt: 0, + apkVersionName: '', + apkVersionCode: 0, + ); + _kioskStatus = const AppKioskStatus.unavailable(); + } + _initialized = true; + } + + static Future refreshKioskStatus() async { + try { + final data = await _channel.invokeMapMethod( + 'getKioskStatus', + ); + _kioskStatus = AppKioskStatus.fromMap(data); + } on PlatformException { + _kioskStatus = const AppKioskStatus.unavailable(); + } + return _kioskStatus; + } + + /// 配置每次进入 App 时是否自动恢复 Kiosk,配置由 Android 原生层持久化。 + static Future setKioskAutoEnterEnabled(bool enabled) async { + try { + final data = await _channel.invokeMapMethod( + 'setKioskAutoEnterEnabled', + {'enabled': enabled}, + ); + _kioskStatus = AppKioskStatus.fromMap(data); + } on PlatformException { + // 保留原状态,设置页会据此判断本次配置没有生效。 + } + return _kioskStatus; + } + + /// 用户确认后通过 Root 请求 Android 配置当前应用为 Device Owner,并立即进入 Kiosk。 + static Future enableKioskWithRoot() async { + try { + final data = await _channel.invokeMapMethod( + 'enableKioskWithRoot', + ); + final provision = AppKioskProvisionResult.fromMap(data); + _kioskStatus = provision.status; + return provision; + } on PlatformException catch (error) { + return AppKioskProvisionResult( + success: false, + code: error.code, + message: error.message ?? 'Kiosk 启用失败,请稍后重试', + status: _kioskStatus, + ); + } + } + + /// 执行带原生 5 秒超时的 `su -c id`,只有退出码为零且输出确认 uid=0 才返回可用。 + static Future checkRootAccess() async { + try { + final data = await _channel.invokeMapMethod( + 'checkRootAccess', + ); + return RootAccessCheckResult.fromMap(data); + } on PlatformException catch (error) { + return RootAccessCheckResult( + status: RootAccessStatus.unavailable, + checkedAt: DateTime.now(), + diagnostic: error.message ?? 'Root 检查通道不可用', + ); + } + } + + /// 由 Android PackageManager 校验 APK 包名、签名和目标版本码。 + static Future validateApk({ + required String path, + required int expectedVersionCode, + }) async { + try { + final data = await _channel.invokeMapMethod( + 'validateApk', + {'path': path, 'expectedVersionCode': expectedVersionCode}, + ); + return AppApkOperationResult.fromMap(data); + } on PlatformException catch (error) { + return AppApkOperationResult( + success: false, + code: error.code, + message: error.message ?? 'APK 校验失败', + ); + } + } + + /// 安装前由原生层再次校验 Root 与 APK,并执行不降级、不卸载数据的 `pm install -r`。 + static Future installApkWithRoot({ + required String path, + required int expectedVersionCode, + }) async { + try { + final data = await _channel.invokeMapMethod( + 'installApkWithRoot', + {'path': path, 'expectedVersionCode': expectedVersionCode}, + ); + return AppApkOperationResult.fromMap(data); + } on PlatformException catch (error) { + return AppApkOperationResult( + success: false, + code: error.code, + message: error.message ?? 'APK 安装失败', + ); + } + } + + /// 启动时消费原生待更新状态,确认版本后原生层同步清理私有 APK。 + static Future consumePendingUpdate() async { + try { + final data = await _channel.invokeMapMethod( + 'consumePendingUpdate', + ); + return AppUpdateRecoveryResult.fromMap(data); + } on PlatformException catch (error) { + return AppUpdateRecoveryResult( + pending: false, + success: false, + message: error.message ?? '自动更新恢复检查失败', + ); + } + } + + /// 只有原生层确认 Device Owner/Kiosk 可用时才允许退出到系统桌面。 + static Future exitKioskToAndroidHome() async { + if (!_kioskStatus.canExitToAndroidHome) return false; + try { + return await _channel.invokeMethod('exitKioskToHome') ?? false; + } on PlatformException { + return false; + } + } + + static Future getNetworkSnapshot() async { + try { + final data = await _channel.invokeMapMethod( + 'getNetworkSnapshot', + ); + return AppNetworkSnapshot.fromMap(data); + } on PlatformException { + return const AppNetworkSnapshot( + connected: false, + type: 'offline', + receivedBytes: 0, + transmittedBytes: 0, + ); + } + } + + /// 读取一次 Android 进程、内存和存储快照;通道失败时返回可安全展示的空快照。 + static Future getPerformanceSnapshot() async { + try { + final data = await _channel.invokeMapMethod( + 'getPerformanceSnapshot', + ); + return AppPerformanceSnapshot.fromMap(data); + } on PlatformException { + return const AppPerformanceSnapshot.empty(); + } + } + + /// 查询插件当前 Presentation 是否仍在展示,用于避免 showSubDisplay() 先关后开造成闪屏。 + static Future isSubScreenShowing() async { + try { + return await _channel.invokeMethod('isSubScreenShowing') ?? false; + } on PlatformException { + return false; + } + } + + /// 单向转发业务状态到副屏 Engine,原生层转发后立即回包。 + /// + /// 锁定的双屏插件不会结束普通业务消息的主屏 MethodChannel 请求,因此人脸等 + /// 硬件主链路必须使用此入口,避免等待副屏回包导致 Camera 初始化被永久阻塞。 + static Future sendViceMessage( + String method, { + Map? params, + }) async { + return await _channel.invokeMethod('sendViceMessage', { + 'method': method, + 'params': params ?? {}, + }) ?? + false; + } + + static void applyManualHardwareInput(bool enabled) { + _manualHardwareInput = enabled; + } +} diff --git a/lib/common/utils/app/app_setting_tool.dart b/lib/common/utils/app/app_setting_tool.dart index 969d2f7..96e5b7d 100644 --- a/lib/common/utils/app/app_setting_tool.dart +++ b/lib/common/utils/app/app_setting_tool.dart @@ -11,6 +11,99 @@ abstract class AppSettingTool { static const String _tag = "app配置工具"; static const String _settingKey = "setting"; + static const String _terminalMenuOrderKey = "terminalMenuOrder"; + static const String _hiddenTerminalMenusKey = "hiddenTerminalMenus"; + static const String _payTypeOrderKey = "payTypeOrder"; + + /// 收银支付方式允许重新识别人员的编码列表;缺失时使用默认集合,空列表表示全部关闭。 + static const String _cashierReidentifiablePayTypesKey = + "cashierReidentifiablePayTypes"; + + /// 收银支付方式的默认重新识别范围;旧配置缺少对应键及用户执行“还原”时均采用该集合。 + static const Set defaultCashierReidentifiablePayTypes = { + // 饭卡支付允许重新刷卡,以便纠正首次识别到的人员。 + OrderPayDisplayType.walletCard, + // 人脸支付允许重新调用摄像头,以便纠正首次识别到的人员。 + OrderPayDisplayType.face, + // 中医院属于线下渠道,默认允许通过饭卡补充人员身份。 + OrderPayDisplayType.zyy, + // 人民医院属于线下渠道,默认允许通过饭卡补充人员身份。 + OrderPayDisplayType.rmyy, + // 超市消费卡属于线下渠道,默认允许通过饭卡补充人员身份。 + OrderPayDisplayType.csxfk, + }; + static const String _cashierShowHoldActionKey = "cashierShowHoldAction"; + static const String _cashierShowMoreActionKey = "cashierShowMoreAction"; + + /// 收银页是否允许主动修改餐段的稳定存储键;缺失时按允许处理,兼容旧终端。 + static const String _cashierAllowManualMealPeriodChangeKey = + "cashierAllowManualMealPeriodChange"; + + /// 顶部隐藏设备编码列表在现有 sysJson 中的稳定存储键;缺失或空列表表示五项全部显示。 + static const String _hiddenTerminalDeviceStatusesKey = + "hiddenTerminalDeviceStatuses"; + + /// 旧版顶部设备状态总开关键,仅在保存逐项配置时清理,读取时不再用于隐藏全部设备。 + static const String _legacyTerminalDeviceStatusVisibleKey = + "terminalDeviceStatusVisible"; + static const String _reservationQueryMethodOrderKey = + "reservationQueryMethodOrder"; + static const String _reservationQueryDefaultMethodKey = + "reservationQueryDefaultMethod"; + + /// 查询菜单自动识别开关在现有 sysJson 中的稳定存储键,缺失时按关闭处理。 + static const String _reservationQueryAutoRecognitionEnabledKey = + "reservationQueryAutoRecognitionEnabled"; + + /// 查询菜单持续自动识别开关在现有 sysJson 中的稳定存储键,缺失时按开启处理。 + static const String _reservationQueryContinuousRecognitionEnabledKey = + "reservationQueryContinuousRecognitionEnabled"; + static const String _reservationQueryNameMaskEnabledKey = + "reservationQueryNameMaskEnabled"; + static const String _reservationQueryVerificationEnabledKey = + "reservationQueryVerificationEnabled"; + + /// 查询菜单快速核销开关在现有 sysJson 中的稳定存储键,缺失时按关闭处理。 + static const String _reservationQueryQuickVerificationEnabledKey = + "reservationQueryQuickVerificationEnabled"; + + /// 查询菜单是否允许主动修改餐段的稳定存储键;缺失时按允许处理,兼容旧终端。 + static const String _reservationQueryAllowManualMealPeriodChangeKey = + "reservationQueryAllowManualMealPeriodChange"; + + /// 查询菜单最近餐段在现有 sysJson 中的稳定存储键,值使用后端餐段编码且不新增数据库字段。 + static const String _reservationQuerySelectedMealSlotKey = + "reservationQuerySelectedMealSlot"; + static const String _reservationQueryVerificationDateScopeKey = + "reservationQueryVerificationDateScope"; + + /// 早餐、中餐和晚餐的本地时间范围;值使用现有 ProductHourType 后端编码。 + static const String _productMealPeriodsKey = "productMealPeriods"; + static const String _serialCardConfigKey = "serialCardConfig"; + + /// v8 之前短暂保存定时任务配置的旧 sysJson 键,仅供一次性迁移读取。 + static const String _scheduledTaskConfigsKey = "scheduledTaskConfigs"; + + /// v8 之前短暂保存任务最近执行结果的旧 sysJson 键,仅供一次性迁移读取。 + static const String _scheduledTaskRecordsKey = "scheduledTaskRecords"; + + /// v8 之前保存日志文件成功上传状态的旧 sysJson 键,仅供一次性迁移读取。 + static const String _terminalLogUploadStatesKey = "terminalLogUploadStates"; + + /// v8 之前保存最近日志上传汇总的旧 sysJson 键,仅供一次性迁移清理。 + static const String _terminalLogUploadSummaryKey = "terminalLogUploadSummary"; + + /// 人工硬件输入历史在现有 sysJson 扩展配置中的存储键。 + static const String _hardwareInputHistoryKey = "hardwareInputHistory"; + + /// Root 状态在 sysJson 中的稳定键,只允许由局部串行更新方法写入。 + static const String _rootAccessStatusKey = 'rootAccessStatus'; + + /// Root 最近检查时间在 sysJson 中的稳定键,使用 ISO-8601 字符串持久化。 + static const String _rootAccessCheckedAtKey = 'rootAccessCheckedAt'; + + /// Root 诊断摘要在 sysJson 中的稳定键,不保存完整 shell 输出。 + static const String _rootAccessDiagnosticKey = 'rootAccessDiagnostic'; // 自定义缓存管理器 static final CacheManager _settingCache = CacheManager( @@ -21,6 +114,24 @@ abstract class AppSettingTool { ), ); + /// setting 单行记录的进程内写入队列,避免 Root 检查、登录刷新和设置页保存相互覆盖。 + static Future _settingWriteQueue = Future.value(); + + /// + /// 将 setting 的“读取最新值并写回”过程串行执行。 + /// + /// setting 表把用户、系统和食堂扩展配置保存在同一行,仅读写前排队才能避免不同字段的 + /// 并发更新互相覆盖;业务异常仍返回原调用方,后续任务会忽略前一次失败继续执行。 + static Future _serializeSettingWrite( + Future Function() action, + ) { + final operation = _settingWriteQueue.catchError((_) {}).then( + (_) => action(), + ); + _settingWriteQueue = operation.then((_) {}).catchError((_) {}); + return operation; + } + static Future removeSettingCache() async { await _settingCache.emptyCache(); } @@ -65,39 +176,73 @@ abstract class AppSettingTool { SysSetting? sysJson, UserSetting? userJson, CanteenExtraPriceSetting? canteenExtraPriceConfig, - }) async { - if (ObjectUtil.isEmpty(sysJson) && - ObjectUtil.isEmpty(userJson) && - ObjectUtil.isEmpty(canteenExtraPriceConfig)) { - return false; - } - final app = Get.find(); - final settingDao = app.dataBase.getSettingDao; - SettingEntity? setting = await settingDao.get(); - if (setting == null) { - logger.e("|$_tag|_setSetting新增错误settingDao.get()方法返回为null"); - return false; - } - if (sysJson != null) { - setting.sysJson = json.encode(sysJson.toJson()); - } - if (userJson != null) { - setting.userJson = json.encode(userJson.toJson()); - } - if (canteenExtraPriceConfig != null) { - setting.canteenExtraPriceConfigJson = json.encode( - canteenExtraPriceConfig.toJson(), - ); - } - await settingDao.edit(setting); + }) => + _serializeSettingWrite(() async { + if (ObjectUtil.isEmpty(sysJson) && + ObjectUtil.isEmpty(userJson) && + ObjectUtil.isEmpty(canteenExtraPriceConfig)) { + return false; + } + final app = Get.find(); + final settingDao = app.dataBase.getSettingDao; + final setting = await settingDao.get(); + if (setting == null) { + logger.e("|$_tag|_setSetting新增错误settingDao.get()方法返回为null"); + return false; + } + if (sysJson != null) { + final current = + Map.from(json.decode(setting.sysJson)); + final updated = sysJson.toJson(); + // Root 状态只归后台检查写入;普通设置使用调用前快照时不能回写过期的 Root 值。 + for (final key in const [ + _rootAccessStatusKey, + _rootAccessCheckedAtKey, + _rootAccessDiagnosticKey, + ]) { + if (current.containsKey(key)) updated[key] = current[key]; + } + // sysJson 允许保存不涉及数据库结构的终端 UI 扩展配置,更新业务设置时必须保留这些键。 + setting.sysJson = json.encode({...current, ...updated}); + } + if (userJson != null) { + setting.userJson = json.encode(userJson.toJson()); + } + if (canteenExtraPriceConfig != null) { + setting.canteenExtraPriceConfigJson = json.encode( + canteenExtraPriceConfig.toJson(), + ); + } + await settingDao.edit(setting); + await _cacheSetting(setting); + return true; + }); + + /// 刷新 setting 单行缓存;调用方必须已经完成数据库写入并持有串行写入队列。 + static Future _cacheSetting(SettingEntity setting) async { await _settingCache.putFile( _settingKey, Uint8List.fromList(utf8.encode(json.encode(setting.toJson()))), fileExtension: 'json', ); - return true; } + /// 在串行队列内读取并局部修改最新 sysJson,未涉及的业务键与同一行其他列保持不变。 + static Future _updateSysJson( + void Function(Map sysJson) update, + ) => + _serializeSettingWrite(() async { + final app = Get.find(); + final settingDao = app.dataBase.getSettingDao; + final setting = await settingDao.get(); + if (setting == null) throw StateError('终端设置尚未初始化'); + final sysJson = Map.from(json.decode(setting.sysJson)); + update(sysJson); + setting.sysJson = json.encode(sysJson); + await settingDao.edit(setting); + await _cacheSetting(setting); + }); + static Future getSysSetting() async { final setting = await _getSetting(); return SysSetting.fromJson(json.decode(setting.sysJson)); @@ -122,6 +267,123 @@ abstract class AppSettingTool { await _setSetting(sysJson: sysInfo); } + /// 保存全局餐段选择到 SysSetting;不新增字段,兼容所有已有终端缓存。 + static Future setSelectedProductHourType(ProductHourType hourType) => + _serializeSettingWrite(() async { + final app = Get.find(); + final settingDao = app.dataBase.getSettingDao; + final setting = await settingDao.get(); + if (setting == null) throw StateError('终端设置尚未初始化'); + final sysJson = Map.from(json.decode(setting.sysJson)); + sysJson['hourType'] = hourType.name; + setting.sysJson = json.encode(sysJson); + await settingDao.edit(setting); + await _cacheSetting(setting); + }); + + /// 串行更新 Root 专属键,始终在实际写入前重新读取 sysJson,避免后台检查覆盖设置页并发修改。 + static Future setRootAccessResult(RootAccessCheckResult result) => + _serializeSettingWrite(() async { + final app = Get.find(); + final settingDao = app.dataBase.getSettingDao; + final setting = await settingDao.get(); + if (setting == null) throw StateError('终端设置尚未初始化'); + final sysJson = Map.from(json.decode(setting.sysJson)); + sysJson[_rootAccessStatusKey] = result.status.name; + sysJson[_rootAccessCheckedAtKey] = result.checkedAt.toIso8601String(); + sysJson[_rootAccessDiagnosticKey] = result.diagnostic; + setting.sysJson = json.encode(sysJson); + await settingDao.edit(setting); + await _cacheSetting(setting); + }); + + /// 读取完整 sysJson 为可修改映射,仅供旧结构化数据迁移方法使用。 + static Future> _getSysExtensionJson() async { + final setting = await _getSetting(); + return Map.from(json.decode(setting.sysJson)); + } + + /// 仅用于把 v8 之前短暂写入 sysJson 的任务配置导入结构化表,不再提供 JSON 写入口。 + static Future> getScheduledTaskConfigs() async { + final sysJson = await _getSysExtensionJson(); + final raw = sysJson[_scheduledTaskConfigsKey]; + if (raw is! List) return const []; + return raw + .whereType() + .map((item) => ScheduledTaskConfig.fromJson( + Map.from(item), + )) + .toList(growable: false); + } + + /// 读取 v8 之前的任务执行快照并按当前枚举过滤未知任务,仅用于迁移。 + static Future> + getScheduledTaskRecords() async { + final sysJson = await _getSysExtensionJson(); + final raw = sysJson[_scheduledTaskRecordsKey]; + if (raw is! Map) return const {}; + final result = {}; + for (final entry in raw.entries) { + ScheduledTaskType? type; + for (final item in ScheduledTaskType.values) { + if (item.name == entry.key.toString()) { + type = item; + break; + } + } + if (type == null || entry.value is! Map) continue; + result[type] = ScheduledTaskRecord.fromJson( + Map.from(entry.value as Map), + ); + } + return result; + } + + /// 读取 v8 之前按相对路径保存的成功上传状态,仅用于导入文件上传记录表。 + static Future> + getTerminalLogUploadStates() async { + final sysJson = await _getSysExtensionJson(); + final raw = sysJson[_terminalLogUploadStatesKey]; + if (raw is! Map) return const {}; + return raw.map( + (key, value) => MapEntry( + key.toString(), + TerminalLogUploadState.fromJson( + Map.from(value as Map), + ), + ), + ); + } + + /// 读取 v8 之前的最近上传汇总;仅保留兼容能力,不再作为运行时数据源。 + static Future getTerminalLogUploadSummary() async { + final sysJson = await _getSysExtensionJson(); + final raw = sysJson[_terminalLogUploadSummaryKey]; + if (raw is! Map) return const TerminalLogUploadSummary(); + return TerminalLogUploadSummary.fromJson(Map.from(raw)); + } + + /// v8 已将任务与上传状态迁入独立表,导入成功后移除旧 JSON,避免形成双写数据源。 + static Future clearLegacyScheduledTaskState({ + required bool includeUploadState, + }) => + _serializeSettingWrite(() async { + final app = Get.find(); + final settingDao = app.dataBase.getSettingDao; + final setting = await settingDao.get(); + if (setting == null) throw StateError('终端设置尚未初始化'); + final sysJson = Map.from(json.decode(setting.sysJson)); + sysJson.remove(_scheduledTaskConfigsKey); + sysJson.remove(_scheduledTaskRecordsKey); + if (includeUploadState) { + sysJson.remove(_terminalLogUploadStatesKey); + sysJson.remove(_terminalLogUploadSummaryKey); + } + setting.sysJson = json.encode(sysJson); + await settingDao.edit(setting); + await _cacheSetting(setting); + }); + static Future setUserSetting(UserSetting userInfo) async { final globalUserInfo = await getUserSetting(); if (globalUserInfo == userInfo) return; @@ -140,6 +402,360 @@ abstract class AppSettingTool { await _setSetting(canteenExtraPriceConfig: canteenExtraPriceConfig); } + /// 读取左侧菜单的顺序和隐藏状态,未知或缺失项由控制器按默认菜单补齐。 + static Future<({List order, Set hidden})> + getTerminalMenuPreferences() async { + final setting = await _getSetting(); + final sysJson = Map.from(json.decode(setting.sysJson)); + final order = (sysJson[_terminalMenuOrderKey] as List?) + ?.whereType() + .toList() ?? + const []; + final hidden = (sysJson[_hiddenTerminalMenusKey] as List?) + ?.whereType() + .toSet() ?? + const {}; + return (order: order, hidden: hidden); + } + + /// 菜单布局属于设备级 UI 设置,直接复用 setting 表中的 sysJson,避免增加数据库字段和迁移。 + static Future setTerminalMenuPreferences({ + required List order, + required Set hidden, + }) => + _updateSysJson((sysJson) { + sysJson[_terminalMenuOrderKey] = order; + sysJson[_hiddenTerminalMenusKey] = hidden.toList(); + }); + + /// 读取支付方式的完整顺序;显隐仍由 SysSetting.usePayDisplayType 维护。 + static Future> getPayTypeOrder() async { + final setting = await _getSetting(); + final sysJson = Map.from(json.decode(setting.sysJson)); + return (sysJson[_payTypeOrderKey] as List?) + ?.whereType() + .toList() ?? + const []; + } + + /// 读取允许重新识别人员的支付方式;缺少或非列表配置使用默认值,显式空列表保持全部关闭。 + static Future> + getCashierReidentifiablePayTypes() async { + final setting = await _getSetting(); + final sysJson = Map.from(json.decode(setting.sysJson)); + final payTypeByCode = { + for (final payType in OrderPayDisplayType.values) payType.code: payType, + }; + final source = sysJson[_cashierReidentifiablePayTypesKey]; + if (source is! List) { + return defaultCashierReidentifiablePayTypes.toSet(); + } + return source + .whereType() + .map((code) => payTypeByCode[code]) + .whereType() + .toSet(); + } + + /// 支付排序、显隐、默认方式及重新识别能力作为同一份终端支付偏好原子保存。 + static Future setPayTypePreferences({ + required SysSetting sysInfo, + required List order, + required Set reidentifiableTypes, + }) { + // 在首次异步等待前生成快照,避免连续拖动时后一次修改污染本次落盘内容。 + final nextSysJson = sysInfo.toJson(); + final visiblePayTypes = nextSysJson['usePayDisplayType']; + final defaultPayType = nextSysJson['defaultPayDisplayType']; + final orderCodes = order.map((item) => item.code).toList(); + final reidentifiableCodes = OrderPayDisplayType.values + .where(reidentifiableTypes.contains) + .map((item) => item.code) + .toList(); + return _updateSysJson((sysJson) { + sysJson['usePayDisplayType'] = visiblePayTypes; + sysJson['defaultPayDisplayType'] = defaultPayType; + sysJson[_payTypeOrderKey] = orderCodes; + sysJson[_cashierReidentifiablePayTypesKey] = reidentifiableCodes; + }); + } + + /// 读取收银页底部快捷操作显隐及餐段修改权限;旧终端没有配置时保持默认允许。 + static Future< + ({ + bool showHoldAction, + bool showMoreAction, + bool allowManualMealPeriodChange, + })> getCashierPagePreferences() async { + final setting = await _getSetting(); + final sysJson = Map.from(json.decode(setting.sysJson)); + return ( + showHoldAction: sysJson[_cashierShowHoldActionKey] != false, + showMoreAction: sysJson[_cashierShowMoreActionKey] != false, + allowManualMealPeriodChange: + sysJson[_cashierAllowManualMealPeriodChangeKey] != false, + ); + } + + /// 收银快捷操作与餐段修改权限属于本机页面布局配置,继续复用 sysJson。 + static Future setCashierPagePreferences({ + required bool showHoldAction, + required bool showMoreAction, + required bool allowManualMealPeriodChange, + }) => + _updateSysJson((sysJson) { + sysJson[_cashierShowHoldActionKey] = showHoldAction; + sysJson[_cashierShowMoreActionKey] = showMoreAction; + sysJson[_cashierAllowManualMealPeriodChangeKey] = + allowManualMealPeriodChange; + }); + + /// 读取商品餐段时间;旧终端无配置或持久化值损坏时返回产品默认时间。 + static Future + getProductMealPeriodSchedule() async { + final setting = await _getSetting(); + final sysJson = Map.from(json.decode(setting.sysJson)); + return ProductMealPeriodSchedule.fromJson(sysJson[_productMealPeriodsKey]); + } + + /// 保存经过统一校验的基础三餐时间,不增加 setting 表字段和数据库迁移。 + static Future setProductMealPeriodSchedule( + ProductMealPeriodSchedule schedule, + ) => + _updateSysJson((sysJson) { + sysJson[_productMealPeriodsKey] = schedule.toJson(); + }); + + /// 按本机当前时间取得配置餐段;未覆盖时段返回空,调用方可保留人工选择。 + static Future getCurrentProductHourType({ + DateTime? now, + }) async { + final schedule = await getProductMealPeriodSchedule(); + return schedule.resolve(now ?? DateTime.now()); + } + + /// 读取顶部需要隐藏的设备类型;缺键、异常值和未知编码均忽略,因此升级后默认五项全部显示。 + static Future> + getHiddenTerminalDeviceStatuses() async { + final setting = await _getSetting(); + final sysJson = Map.from(json.decode(setting.sysJson)); + final source = sysJson[_hiddenTerminalDeviceStatusesKey]; + if (source is! List) return {}; + final deviceByCode = { + for (final device in TerminalDeviceType.values) device.code: device, + }; + return source + .whereType() + .map((code) => deviceByCode[code]) + .whereType() + .toSet(); + } + + /// 保存逐项隐藏设备编码并移除旧总开关;新设备因不在隐藏列表中会自动显示。 + static Future setHiddenTerminalDeviceStatuses( + Set hiddenDevices, + ) { + final hiddenCodes = TerminalDeviceType.values + .where(hiddenDevices.contains) + .map((device) => device.code) + .toList(growable: false); + return _updateSysJson((sysJson) { + sysJson[_hiddenTerminalDeviceStatusesKey] = hiddenCodes; + sysJson.remove(_legacyTerminalDeviceStatusVisibleKey); + }); + } + + /// 读写卡协议配置属于设备本地扩展设置,复用 sysJson 保存且不修改数据库结构。 + static Future getSerialCardConfig() async { + final setting = await _getSetting(); + final sysJson = Map.from(json.decode(setting.sysJson)); + final source = sysJson[_serialCardConfigKey]; + if (source is! Map) return const SerialCardConfig(); + try { + return SerialCardConfig.fromJson(Map.from(source)); + } on Object { + // 旧版本或人工修改的异常配置不能阻断收银读卡,无法解析时回退设备默认参数。 + return const SerialCardConfig(); + } + } + + /// 密钥只写入本机设置和串口模块,不记录到日志,也不随普通业务接口上传。 + static Future setSerialCardConfig(SerialCardConfig config) => + _updateSysJson((sysJson) { + sysJson[_serialCardConfigKey] = config.toJson(); + }); + + /// 查询方式顺序和默认项属于页面交互配置,继续保存在 sysJson 中,不增加数据库字段。 + static Future + getReservationQueryPagePreferences() async { + final setting = await _getSetting(); + final sysJson = Map.from(json.decode(setting.sysJson)); + final byCode = { + for (final method in ReservationRecognitionMethod.values) + method.code: method, + }; + final restored = []; + final storedOrder = + (sysJson[_reservationQueryMethodOrderKey] as List?) + ?.whereType() ?? + const []; + for (final code in storedOrder) { + final method = byCode[code]; + if (method != null && !restored.contains(method)) restored.add(method); + } + for (final method in ReservationRecognitionMethod.values) { + if (!restored.contains(method)) restored.add(method); + } + final storedDefault = + byCode[sysJson[_reservationQueryDefaultMethodKey] as String?]; + final defaultMethod = + storedDefault != null && restored.contains(storedDefault) + ? storedDefault + : restored.first; + final verificationDateScope = ReservationVerificationDateScope.values + .where( + (scope) => + scope.code == + sysJson[_reservationQueryVerificationDateScopeKey], + ) + .firstOrNull ?? + ReservationVerificationDateScope.today; + final verificationEnabled = + sysJson[_reservationQueryVerificationEnabledKey] == true; + final selectedMealSlot = ReservationMealSlot.querySelectableValues + .where( + (slot) => + slot.code == sysJson[_reservationQuerySelectedMealSlotKey], + ) + .firstOrNull ?? + ReservationMealSlot.morning; + return ReservationQueryPagePreferences( + methodOrder: List.unmodifiable(restored), + defaultMethod: defaultMethod, + // 旧设置没有该键时保持手动启动,避免升级后直接拉起读卡器或相机。 + autoRecognitionEnabled: + sysJson[_reservationQueryAutoRecognitionEnabledKey] == true, + // 自动识别的设计目标是持续接待,旧设置没有该键时默认续开正常等待超时。 + continuousRecognitionEnabled: + sysJson[_reservationQueryContinuousRecognitionEnabledKey] != false, + // 旧设置没有该键时继续脱敏,避免升级后意外展示完整姓名。 + nameMaskEnabled: sysJson[_reservationQueryNameMaskEnabledKey] != false, + verificationEnabled: verificationEnabled, + // 快速核销从属于核销入口,旧设置缺键或核销关闭时均不得隐式生效。 + quickVerificationEnabled: verificationEnabled && + sysJson[_reservationQueryQuickVerificationEnabledKey] == true, + // 旧设置没有该键时保持允许手动修改,避免升级后收银流程被意外锁死。 + allowManualMealPeriodChange: + sysJson[_reservationQueryAllowManualMealPeriodChangeKey] != false, + selectedMealSlot: selectedMealSlot, + verificationDateScope: verificationDateScope, + ); + } + + /// 顺序与默认项一起保存,避免连续操作后出现“默认项不在当前列表中”的矛盾状态。 + static Future setReservationQueryPagePreferences({ + required List order, + required ReservationRecognitionMethod defaultMethod, + required bool autoRecognitionEnabled, + required bool continuousRecognitionEnabled, + required bool nameMaskEnabled, + required bool verificationEnabled, + required bool quickVerificationEnabled, + required bool allowManualMealPeriodChange, + required ReservationVerificationDateScope verificationDateScope, + }) { + final normalized = []; + for (final method in order) { + if (!normalized.contains(method)) normalized.add(method); + } + for (final method in ReservationRecognitionMethod.values) { + if (!normalized.contains(method)) normalized.add(method); + } + final normalizedDefault = + normalized.contains(defaultMethod) ? defaultMethod : normalized.first; + return _updateSysJson((sysJson) { + sysJson[_reservationQueryMethodOrderKey] = + normalized.map((method) => method.code).toList(); + sysJson[_reservationQueryDefaultMethodKey] = normalizedDefault.code; + sysJson[_reservationQueryAutoRecognitionEnabledKey] = + autoRecognitionEnabled; + sysJson[_reservationQueryContinuousRecognitionEnabledKey] = + continuousRecognitionEnabled; + sysJson[_reservationQueryNameMaskEnabledKey] = nameMaskEnabled; + sysJson[_reservationQueryVerificationEnabledKey] = verificationEnabled; + sysJson[_reservationQueryQuickVerificationEnabledKey] = + verificationEnabled && quickVerificationEnabled; + sysJson[_reservationQueryAllowManualMealPeriodChangeKey] = + allowManualMealPeriodChange; + sysJson[_reservationQueryVerificationDateScopeKey] = + verificationDateScope.code; + }); + } + + /// 保存查询菜单最近选中的基础三餐,并刷新现有 setting 文件缓存供下次进入页面恢复。 + static Future setReservationQuerySelectedMealSlot( + ReservationMealSlot mealSlot, + ) { + if (!ReservationMealSlot.querySelectableValues.contains(mealSlot)) { + throw ArgumentError.value(mealSlot, 'mealSlot', '仅允许缓存早餐、午餐或晚餐'); + } + return _updateSysJson((sysJson) { + sysJson[_reservationQuerySelectedMealSlotKey] = mealSlot.code; + }); + } + + /// 读取指定“路由 + 功能键”场景最近使用的人工硬件输入,异常旧数据会被忽略。 + static Future> getHardwareInputHistory( + String sceneKey, + ) async { + final store = await _getHardwareInputHistoryStore(); + return List.unmodifiable(store.entriesFor(sceneKey)); + } + + /// 保存当前场景最近 5 条输入;相同输入只更新备注和时间并置顶,不产生重复记录。 + static Future saveHardwareInputHistory({ + required String sceneKey, + required String value, + String? remark, + }) async { + final normalizedValue = value.trim(); + if (normalizedValue.isEmpty) return; + await _updateSysJson((sysJson) { + final store = _decodeHardwareInputHistoryStore( + sysJson[_hardwareInputHistoryKey], + ); + final nextStore = store.save( + sceneKey: sceneKey, + value: normalizedValue, + remark: remark ?? '', + updatedAt: DateTime.now(), + ); + sysJson[_hardwareInputHistoryKey] = nextStore.toJson(); + }); + } + + /// 从现有 sysJson 扩展配置读取强类型历史模型,单条异常数据不影响人工输入主流程。 + static Future + _getHardwareInputHistoryStore() async { + final setting = await _getSetting(); + final sysJson = Map.from(json.decode(setting.sysJson)); + return _decodeHardwareInputHistoryStore(sysJson[_hardwareInputHistoryKey]); + } + + /// JSON Map 仅停留在设置存储边界,弹窗与去重逻辑统一操作强类型模型。 + static HardwareInputHistoryStore _decodeHardwareInputHistoryStore( + Object? source, + ) { + if (source is! Map) return HardwareInputHistoryStore.empty; + try { + return HardwareInputHistoryStore.fromJson( + Map.from(source), + ); + } on Object { + return HardwareInputHistoryStore.empty; + } + } + // 根据后台返回的数据更新到缓存 static Future updateSysInfoByService(ServiceHardware data) async { if (ObjectUtil.isEmpty(data)) { @@ -223,6 +839,50 @@ abstract class AppSettingTool { await _setSetting(sysJson: sysSetting); } + /// 将当前业务终端的打印机绑定和打印参数持久化到本机;空响应表示尚未绑定。 + static Future updatePrinterBinding( + Map? binding, + ) async { + final setting = await getSysSetting(); + if (binding == null) { + setting.printerHardId = null; + setting.printerHardName = null; + setting.printerModel = null; + setting.printerUsbVendorId = null; + setting.printerUsbProductId = null; + setting.printerPaperWidth = 80; + setting.printerAutoCut = true; + setting.printerImageMode = 'GS_V0'; + await _setSetting(sysJson: setting); + return setting; + } + final sourceHardId = binding['sourceHardId']?.toString(); + if (sourceHardId != setting.serviceId) { + throw const FormatException('服务端打印机绑定与当前业务终端不匹配'); + } + final sourceType = SysType.values.fromCode( + binding['sourceHardType']?.toString() ?? '', + ); + if (sourceType != setting.sysType) { + throw const FormatException('服务端打印机绑定的业务类型与当前终端不匹配'); + } + final config = binding['printerConfig'] is Map + ? Map.from(binding['printerConfig'] as Map) + : {}; + setting.printerHardId = binding['printerHardId']?.toString(); + setting.printerHardName = binding['printerHardName']?.toString(); + setting.printerModel = config['printerModel']?.toString(); + setting.printerUsbVendorId = int.tryParse('${config['usbVendorId'] ?? ''}'); + setting.printerUsbProductId = + int.tryParse('${config['usbProductId'] ?? ''}'); + setting.printerPaperWidth = + int.tryParse('${config['paperWidth'] ?? ''}') ?? 80; + setting.printerAutoCut = '${config['autoCut'] ?? '1'}' != '0'; + setting.printerImageMode = config['imageMode']?.toString() ?? 'GS_V0'; + await _setSetting(sysJson: setting); + return setting; + } + /// 绑定设备信息 static Future bindSys(int serviceId) async { var androidId = await AppSysTool.getAndroidId(); diff --git a/lib/common/utils/app/app_sub_screen_tool.dart b/lib/common/utils/app/app_sub_screen_tool.dart new file mode 100644 index 0000000..3895778 --- /dev/null +++ b/lib/common/utils/app/app_sub_screen_tool.dart @@ -0,0 +1,88 @@ +import 'package:flutter_subscreen_plugin/flutter_subscreen_plugin.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 统一维护副屏常驻状态,确保业务切换只发送页面消息,不重复重建 Presentation。 +abstract class AppSubScreenTool { + static bool _available = false; + static bool _presentationRequested = false; + static Future? _showFuture; + + static bool get isAvailable => _available; + + static Future refreshAvailability({bool reportStatus = true}) async { + await AppRuntimeTool.initializePlatform(); + if (AppRuntimeTool.isEmulator || !AppRuntimeTool.hasMultipleDisplays) { + _available = false; + _presentationRequested = false; + AppDeviceStatusTool.updateSubScreenWorking(false); + if (reportStatus) AppDeviceStatusTool.updateSubScreen(false); + return false; + } + try { + final hasViceScreen = await SubScreenPlugin.isMultipleScreen; + final hasOverlayPermission = await SubScreenPlugin.checkOverlayPermission; + _available = hasViceScreen && hasOverlayPermission; + if (!_available) { + _presentationRequested = false; + AppDeviceStatusTool.updateSubScreenWorking(false); + } + } catch (e) { + _available = false; + _presentationRequested = false; + AppDeviceStatusTool.updateSubScreenWorking(false); + logger.w("|副屏工具|检测副屏失败:$e"); + } + if (reportStatus) AppDeviceStatusTool.updateSubScreen(_available); + return _available; + } + + static Future show({bool verifyPresentation = false}) async { + if (_presentationRequested && !verifyPresentation) { + AppDeviceStatusTool.updateSubScreenWorking(true); + return true; + } + final pending = _showFuture; + if (pending != null) return pending; + final future = _showOnce(verifyPresentation: verifyPresentation); + _showFuture = future; + try { + return await future; + } finally { + if (identical(_showFuture, future)) _showFuture = null; + } + } + + static Future _showOnce({required bool verifyPresentation}) async { + final available = await refreshAvailability(); + if (!available) return false; + if (_presentationRequested && !verifyPresentation) return true; + try { + if (await AppRuntimeTool.isSubScreenShowing()) { + _presentationRequested = true; + AppDeviceStatusTool.updateSubScreenWorking(true); + return true; + } + // 插件 showSubDisplay() 会先关闭旧 Presentation;仅首次确认可用时调用, + // 后续业务入口只复用常驻副屏,避免顾客屏反复黑屏或闪烁。 + SubScreenPlugin.doubleScreenShow(); + _presentationRequested = true; + AppDeviceStatusTool.updateSubScreenWorking(true); + return true; + } catch (e) { + _presentationRequested = false; + _available = false; + AppDeviceStatusTool.updateSubScreenWorking(false); + AppDeviceStatusTool.updateSubScreen(false); + logger.w("|副屏工具|打开副屏失败:$e"); + return false; + } + } + + /// 兼容旧调用入口。应用运行期间禁止业务代码关闭 Presentation, + /// Android Activity 真正销毁时仍由原生层统一释放副屏窗口和 Engine。 + static void hide() { + if (!_available) return; + logger.i("|副屏工具|忽略业务关闭请求,副屏保持常驻"); + } +} diff --git a/lib/common/utils/app/control/product_hour_type_control.dart b/lib/common/utils/app/control/product_hour_type_control.dart new file mode 100644 index 0000000..d7b1dfc --- /dev/null +++ b/lib/common/utils/app/control/product_hour_type_control.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 应用级餐段状态;收银、查询菜单和其他餐段入口都复用同一个选择器与缓存值。 +class ProductHourTypeControl extends GetxController { + /// 创建默认选中早餐的全局餐段控制器;真正进入业务前由 [initAsync] 恢复本机缓存。 + ProductHourTypeControl({ + ProductHourType initial = ProductHourType.MORNING, + bool persistSelection = true, + }) : _persistSelection = persistSelection, + selected = initial.obs, + selection = FRadioSelectGroupController(value: initial.code) { + selection.addValueListener(_handleSelectionChanged); + } + + /// 当前全局餐段,供需要强类型值的业务逻辑使用。 + final Rx selected; + + /// 公共 UI 选择器使用的单选 Controller;页面不得自行创建餐段 Controller。 + final FRadioSelectGroupController selection; + + /// 首次缓存恢复任务;重复进入页面时复用同一个 Future,避免并发读取与重复迁移。 + Future? _initializationFuture; + + /// 内部同步强类型值与 Forui 选择器时暂时屏蔽监听器,防止重复持久化。 + bool _suppressPersistence = false; + + /// 是否把选择写入终端设置;独立单测实例可关闭,应用级实例始终开启。 + final bool _persistSelection; + + /// 当前餐段缓存恢复完成信号。 + Future get initialization => _initializationFuture ?? Future.value(); + + /// 读取 SysSetting 中缓存的餐段;旧版本没有缓存时尝试迁移查询菜单的历史选择。 + Future initAsync() { + return _initializationFuture ??= _initialize(); + } + + /// 执行一次缓存恢复,并兼容迁移旧版查询菜单保存的最近餐段。 + Future _initialize() async { + try { + final setting = await AppSettingTool.getSysSetting(); + var restored = setting.hourType; + if (restored == null) { + try { + final preferences = + await AppSettingTool.getReservationQueryPagePreferences(); + restored = ProductHourType.values.fromCode( + preferences.selectedMealSlot.code, + ); + } catch (_) { + // 旧配置没有查询菜单缓存时继续使用早餐默认值。 + } + } + if (restored != null && restored != ProductHourType.TOTAL) { + _setSelection(restored, persist: setting.hourType == null); + } + } catch (error, stack) { + logger.w('|餐段|恢复全局餐段缓存失败error:|$error|stack:|$stack|'); + } + } + + /// 由页面或公共组件请求切换餐段;可在初始化迁移时关闭持久化。 + void setSelected(ProductHourType next, {bool persist = true}) { + if (next == ProductHourType.TOTAL || selected.value == next) return; + _setSelection(next, persist: persist); + } + + /// 接收公共 Forui 选择器变化,立即更新全局值并异步写入本机缓存。 + void _handleSelectionChanged(Set values) { + if (_suppressPersistence) return; + final next = ProductHourType.values.fromCode(values.firstOrNull); + if (next == null || next == ProductHourType.TOTAL) return; + if (selected.value == next) return; + selected.value = next; + if (_persistSelection) { + unawaited(AppSettingTool.setSelectedProductHourType(next)); + } + } + + /// 原子同步全局强类型值和 UI 选择值,并按调用场景决定是否写入缓存。 + void _setSelection(ProductHourType next, {required bool persist}) { + _suppressPersistence = true; + try { + selected.value = next; + if (selection.value.firstOrNull != next.code) { + selection.value = {next.code}; + } + } finally { + _suppressPersistence = false; + } + if (persist && _persistSelection) { + unawaited(AppSettingTool.setSelectedProductHourType(next)); + } + } + + /// 应用级控制器被显式销毁时释放选择器监听与 Forui 资源。 + @override + void onClose() { + selection.removeValueListener(_handleSelectionChanged); + selection.dispose(); + super.onClose(); + } +} diff --git a/lib/common/utils/index.dart b/lib/common/utils/index.dart index 99d2483..95aa63a 100644 --- a/lib/common/utils/index.dart +++ b/lib/common/utils/index.dart @@ -1,9 +1,13 @@ library; export './app/control/app_control.dart'; +export './app/control/product_hour_type_control.dart'; export './app/control/app_theme_control.dart'; export './app/app_face_tool.dart'; +export './app/app_device_status_tool.dart'; +export './app/app_runtime_tool.dart'; +export './app/app_sub_screen_tool.dart'; export './app/app_file_tool.dart'; export './app/app_mq_tool.dart'; export './app/app_network_tool.dart'; @@ -38,6 +42,13 @@ export './plugs/face_discern_plugs.dart'; export './router/router_observer_tool.dart'; export './sync/sync_check_tool.dart'; +export './sync/scheduled_task_storage_tool.dart'; +export './sync/terminal_heartbeat_tool.dart'; +export './sync/terminal_auto_update_tool.dart'; +export './sync/terminal_log_upload_tool.dart'; +export './sync/file_upload_record_tool.dart'; +export './sync/order_evidence_upload_tool.dart'; +export './sync/sync_idle_tool.dart'; export './sync/sync_config_tool.dart'; export './sync/sync_face_tool.dart'; export './sync/sync_order_tool.dart'; diff --git a/lib/common/utils/json/json_tool.dart b/lib/common/utils/json/json_tool.dart index c3bb5fd..1c8bc37 100644 --- a/lib/common/utils/json/json_tool.dart +++ b/lib/common/utils/json/json_tool.dart @@ -1,3 +1,5 @@ +import 'package:common_utils/common_utils.dart'; + import 'package:pj_supermarket_cloud/common/index.dart'; abstract class JsonTool { @@ -16,6 +18,28 @@ abstract class JsonTool { return int.tryParse(value); } + /// 兼容后端 Long 以字符串或数字返回的单值场景。 + static int anyToInt(Object? value) { + if (value is int) return value; + if (value is num) return value.toInt(); + final parsed = int.tryParse(value.toString()); + if (parsed == null) throw const FormatException('数字格式错误'); + return parsed; + } + + /// 兼容可选 Long 以字符串、数字或空值返回的场景,空值保持为空。 + static int? anyToNullableInt(Object? value) { + if (value == null) return null; + return anyToInt(value); + } + + /// 兼容后端 BigDecimal 以字符串或数字返回的可选小数。 + static double? anyToNullableDouble(Object? value) { + if (value == null) return null; + if (value is num) return value.toDouble(); + return double.tryParse(value.toString()); + } + static String? intToString(int? value) { if (value == null) return null; return value.toString(); @@ -26,6 +50,33 @@ abstract class JsonTool { return value.toString(); } + /// 将后端必填 Long 或字符串标识转换为非空字符串;缺失或空值时在模型边界直接拒绝。 + static String anyToRequiredString(Object? value) { + final result = anyToString(value)?.trim(); + if (result == null || result.isEmpty) { + throw const FormatException('必填标识不能为空'); + } + return result; + } + + /// 将 Flutter 时间转换为 Java 后端 LocalDateTime 的统一请求格式,不携带时区和小数秒。 + static String dateTimeToServiceString(DateTime value) => + DateUtil.formatDate(value, format: DateFormats.full); + + /// 后端为避免 19 位 Long 在部分客户端丢失精度,可能把 ID 序列化为字符串。 + /// 这里同时兼容字符串和数字,忽略无法安全转换的异常项。 + static List anyListToIntList(Object? value) { + if (value is! List) return const []; + return value + .map((item) { + if (item is int) return item; + if (item is num) return item.toInt(); + return int.tryParse(item.toString()); + }) + .whereType() + .toList(growable: false); + } + static Map? anyToMap(dynamic value) { if (value == null) return null; return dynamic as Map?; diff --git a/lib/common/utils/logger/logger_tool.dart b/lib/common/utils/logger/logger_tool.dart index b1e55a3..82fec41 100644 --- a/lib/common/utils/logger/logger_tool.dart +++ b/lib/common/utils/logger/logger_tool.dart @@ -7,10 +7,11 @@ import 'package:logger/logger.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +final _consoleOutput = ConsoleOutput(); final logger = Logger( filter: AppLogFilter(), printer: customPrinter, - output: ConsoleOutput(), + output: _consoleOutput, ); final customPrinter = PrettyPrinter( lineLength: 0, @@ -22,6 +23,123 @@ final customPrinter = PrettyPrinter( noBoxingByDefault: true, ); +/// 设置页使用的日志尾部快照,只返回脱敏后的有限内容,避免大文件阻塞收银主线程。 +Future readAppLogPreview({ + String? path, + int maxBytes = 256 * 1024, + int maxLines = 1000, +}) async { + await logger.init; + return _consoleOutput.readPreview( + path: path, + maxBytes: maxBytes, + maxLines: maxLines, + ); +} + +/// 列出日志根目录中的文件,设置页据此构建可切换的日志目录树。 +Future listAppLogFiles() async { + await logger.init; + return _consoleOutput.listLogFiles(); +} + +/// 上传当前日志前刷新文件写入队列,保证快照边界包含调用前已经产生的日志。 +Future flushCurrentAppLog() async { + await logger.init; + await _consoleOutput.flushCurrentLog(); +} + +/// 逐行生成脱敏副本,上传工具不会把原始日志文件直接交给网络层。 +Future sanitizeAppLogFile({ + required File source, + required File target, + int? maxSourceBytes, +}) async { + final sourceLength = await source.length(); + final limit = maxSourceBytes == null + ? sourceLength + : maxSourceBytes.clamp(0, sourceLength); + final input = await source.open(); + IOSink? output; + try { + output = target.openWrite(mode: FileMode.writeOnly); + var position = 0; + var pending = ''; + const chunkSize = 64 * 1024; + while (position < limit) { + final length = (limit - position).clamp(0, chunkSize); + final bytes = await input.read(length); + if (bytes.isEmpty) break; + position += bytes.length; + pending += utf8.decode(bytes, allowMalformed: true); + final lines = pending.split('\n'); + pending = lines.removeLast(); + for (final line in lines) { + output.writeln(sanitizeAppLogContent(line)); + } + } + if (pending.isNotEmpty) output.write(sanitizeAppLogContent(pending)); + await output.flush(); + } finally { + await input.close(); + await output?.close(); + } +} + +/// 日志预览和上传共用同一套脱敏规则,避免两个入口出现隐私边界漂移。 +String sanitizeAppLogContent(String content) => + _sanitizePreviewContent(content); + +class AppLogCatalog { + const AppLogCatalog({ + required this.rootPath, + required this.currentPath, + required this.files, + }); + + final String rootPath; + final String currentPath; + final List files; +} + +class AppLogFile { + const AppLogFile({ + required this.path, + required this.relativePath, + required this.name, + required this.fileSize, + required this.modifiedAt, + required this.isCurrent, + required this.exists, + }); + + final String path; + final String relativePath; + final String name; + final int fileSize; + final DateTime? modifiedAt; + final bool isCurrent; + final bool exists; +} + +class AppLogPreview { + const AppLogPreview({ + required this.path, + required this.content, + required this.fileSize, + required this.modifiedAt, + required this.lineCount, + required this.truncated, + }); + + final String path; + final String content; + final int fileSize; + final DateTime? modifiedAt; + final int lineCount; + final bool truncated; +} + class AppLogFilter extends LogFilter { @override bool shouldLog(LogEvent event) { @@ -42,6 +160,154 @@ class ConsoleOutput extends LogOutput { return _queue; } + /// 排在既有写入队列末尾刷新当前文件,完成后调用方可取得一致的文件大小快照。 + Future flushCurrentLog() => _enqueue(() async => _sink?.flush()); + + /// 读取当前日志文件尾部,并在返回 UI 前移除终端颜色码和常见敏感字段。 + Future readPreview({ + String? path, + required int maxBytes, + required int maxLines, + }) async { + if (maxBytes <= 0 || maxLines <= 0) { + throw ArgumentError('日志预览范围必须大于 0'); + } + final currentPath = await _resolveCurrentLogPath(); + final resolvedPath = path ?? currentPath; + await _validatePreviewPath(resolvedPath, currentPath); + if (_samePath(resolvedPath, currentPath)) { + await _enqueue(() async => _sink?.flush()); + } + final file = File(resolvedPath); + if (!await file.exists()) { + return AppLogPreview( + path: resolvedPath, + content: '', + fileSize: 0, + modifiedAt: null, + lineCount: 0, + truncated: false, + ); + } + + final stat = await file.stat(); + final start = stat.size > maxBytes ? stat.size - maxBytes : 0; + final handle = await file.open(); + late final List bytes; + try { + await handle.setPosition(start); + bytes = await handle.read(stat.size - start); + } finally { + await handle.close(); + } + + var content = utf8.decode(bytes, allowMalformed: true); + content = _sanitizePreviewContent(content); + if (start > 0) content = _removePartialLeadingEntry(content); + + var lines = const LineSplitter().convert(content); + var truncated = start > 0; + if (lines.length > maxLines) { + lines = lines.sublist(lines.length - maxLines); + truncated = true; + } + return AppLogPreview( + path: resolvedPath, + content: lines.join('\n').trimRight(), + fileSize: stat.size, + modifiedAt: stat.modified, + lineCount: lines.length, + truncated: truncated, + ); + } + + /// 只扫描应用日志根目录中的 `.log` 文件,避免设置页变成任意文件浏览器。 + Future listLogFiles() async { + final currentPath = await _resolveCurrentLogPath(); + await _enqueue(() async => _sink?.flush()); + final root = Directory(currentPath).parent.parent; + final files = []; + if (await root.exists()) { + await for (final entity + in root.list(recursive: true, followLinks: false)) { + if (entity is! File || !entity.path.toLowerCase().endsWith('.log')) { + continue; + } + try { + final stat = await entity.stat(); + files.add( + AppLogFile( + path: entity.path, + relativePath: _relativePath(root.path, entity.path), + name: _fileName(entity.path), + fileSize: stat.size, + modifiedAt: stat.modified, + isCurrent: _samePath(entity.path, currentPath), + exists: true, + ), + ); + } on FileSystemException { + // 扫描期间文件可能被外部清理,跳过失效项即可由下一次刷新恢复。 + } + } + } + + if (!files.any((file) => file.isCurrent)) { + files.add( + AppLogFile( + path: currentPath, + relativePath: _relativePath(root.path, currentPath), + name: _fileName(currentPath), + fileSize: 0, + modifiedAt: null, + isCurrent: true, + exists: false, + ), + ); + } + files.sort((a, b) { + if (a.isCurrent != b.isCurrent) return a.isCurrent ? -1 : 1; + final timeCompare = (b.modifiedAt ?? + DateTime.fromMillisecondsSinceEpoch(0)) + .compareTo(a.modifiedAt ?? DateTime.fromMillisecondsSinceEpoch(0)); + if (timeCompare != 0) return timeCompare; + return b.relativePath.compareTo(a.relativePath); + }); + return AppLogCatalog( + rootPath: root.path, + currentPath: currentPath, + files: List.unmodifiable(files), + ); + } + + Future _resolveCurrentLogPath() async { + final path = _filePath ?? await AppFileTool.getLoggerBackupPath(); + if (ObjectUtil.isEmpty(path)) { + throw const FileSystemException('无法获取日志文件路径'); + } + return path!; + } + + Future _validatePreviewPath( + String path, + String currentPath, + ) async { + if (!path.toLowerCase().endsWith('.log')) { + throw const FileSystemException('仅支持预览日志文件'); + } + final root = Directory(currentPath).parent.parent; + final candidate = File(path); + final rootPath = await root.exists() + ? await root.resolveSymbolicLinks() + : root.absolute.path; + final candidatePath = await candidate.exists() + ? await candidate.resolveSymbolicLinks() + : candidate.absolute.path; + if (!_isPathInside(rootPath, candidatePath)) { + throw const FileSystemException('日志文件不在允许的目录中'); + } + } + @override Future init() async { try { @@ -88,6 +354,7 @@ class ConsoleOutput extends LogOutput { }); } + /// 开发模式保留现有文件记录范围;生产模式仅持久化达到配置阈值的日志。 @override void output(OutputEvent event) { try { @@ -98,6 +365,10 @@ class ConsoleOutput extends LogOutput { } unawaited(_enqueue(() async { if (_filePath == null || _sink == null) return; + if (AppSysTool.isProd() && + event.level.value < Constants.productionFileLogLevel.value) { + return; + } if (Level.debug == event.level) return; _sink!.writeAll(event.lines, '\n'); _sink!.writeln(); @@ -108,3 +379,68 @@ class ConsoleOutput extends LogOutput { } } } + +String _removePartialLeadingEntry(String content) { + final entryEnd = content.indexOf('\n\n'); + if (entryEnd >= 0) return content.substring(entryEnd + 2); + final lineEnd = content.indexOf('\n'); + return lineEnd >= 0 ? content.substring(lineEnd + 1) : ''; +} + +bool _samePath(String first, String second) { + return File(first).absolute.path == File(second).absolute.path; +} + +bool _isPathInside(String rootPath, String candidatePath) { + final root = Directory(rootPath).absolute.path; + final candidate = File(candidatePath).absolute.path; + return candidate == root || + candidate.startsWith('$root${Platform.pathSeparator}'); +} + +String _relativePath(String rootPath, String filePath) { + final root = Directory(rootPath).absolute.path.replaceAll('\\', '/'); + final file = File(filePath).absolute.path.replaceAll('\\', '/'); + final prefix = '$root/'; + return file.startsWith(prefix) + ? file.substring(prefix.length) + : _fileName(file); +} + +String _fileName(String path) { + return path.replaceAll('\\', '/').split('/').last; +} + +String _sanitizePreviewContent(String content) { + var value = content.replaceAll( + RegExp(r'\x1B\[[0-?]*[ -/]*[@-~]'), + '', + ); + value = value.replaceAllMapped( + RegExp( + r'^.*(?:用户信息|下单参数|存储用户|同步参数|同步设备信息\s+sysInfo|二维码识别.*json|绑定设备信息pa:|添加到.*记录.*json).*$', + caseSensitive: false, + multiLine: true, + ), + (_) => '[包含敏感业务数据的日志已隐藏]', + ); + value = value.replaceAllMapped( + RegExp(r'Bearer\s+[A-Za-z0-9._~+/=-]+', caseSensitive: false), + (_) => 'Bearer ***', + ); + value = value.replaceAllMapped( + RegExp( + r'((?:"|\b)?(?:token|accessToken|refreshToken|password|passwd|secret|authorization|paymentCode|payCode|authCode|qrCode|serviceCode|shortCode|cardId|cardNo|cardNumber|phone|mobile|idCard|certNo|realName|userName|nickName|address|email|userId|orderId|serviceOrderId)(?:"|\b)?\s*(?:[:=]|\|)\s*)("[^"]*"|[^,\s;|}\]]+)', + caseSensitive: false, + ), + (match) { + final source = match.group(2)!; + return '${match.group(1)}${source.startsWith('"') ? '"***"' : '***'}'; + }, + ); + // 未带字段名的长编号也可能是手机号、饭卡号或付款码,仅保留定位所需的首尾片段。 + return value.replaceAllMapped(RegExp(r'\d{8,}'), (match) { + final source = match.group(0)!; + return '${source.substring(0, 3)}****${source.substring(source.length - 4)}'; + }); +} diff --git a/lib/common/utils/manage/base/serial_base.dart b/lib/common/utils/manage/base/serial_base.dart index edb56d0..a1b99e1 100644 --- a/lib/common/utils/manage/base/serial_base.dart +++ b/lib/common/utils/manage/base/serial_base.dart @@ -22,6 +22,9 @@ class SerialBase { _portName = portName; } + /// 当前绑定的串口节点名,供设备状态探测读取。 + String get portName => _portName; + set baudRate(int baudRate) { _baudRate = baudRate; } diff --git a/lib/common/utils/manage/http_manage.dart b/lib/common/utils/manage/http_manage.dart index 2f06700..7a6688b 100644 --- a/lib/common/utils/manage/http_manage.dart +++ b/lib/common/utils/manage/http_manage.dart @@ -1,26 +1,73 @@ +import 'dart:convert'; import 'dart:io'; import 'package:common_utils/common_utils.dart'; import 'package:dio/dio.dart'; import 'package:dio/io.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; -import 'package:get/get.dart'; +import 'package:get/get.dart' hide FormData; import 'package:pj_supermarket_cloud/common/index.dart'; +/// 无需向操作员重复提示的服务端业务消息。 const ignoreMsg = ["账号未登录"]; +/// 应用统一 Dio 管理器,负责鉴权头、租户头、响应解包、错误恢复与安全日志。 class HttpManage { - static const _tag = "http管理"; - static const _requestStartAtKey = "_requestStartAt"; - static const _maxLogLength = 1000; + /// 后台心跳、版本检查等旁路请求禁止弹 Toast 时写入 Dio RequestOptions.extra 的稳定键。 + static const String silentRequestKey = 'silentRequest'; + /// 需要把统一业务非零码作为 Dio 异常交给调用方区分明确失败与超时的请求标记。 + static const String rejectBusinessErrorKey = 'rejectBusinessError'; + + /// HTTP 管理日志标签。 + static const _tag = "http管理"; + + /// 请求开始时间在 Dio extra 中使用的内部键,用于计算请求耗时。 + static const _requestStartAtKey = "_requestStartAt"; + + /// 单段请求或响应日志允许保留的最大字符数,超出部分只记录原长度。 + static const _maxLogLength = 2000; + + /// 日志输出前必须遮蔽的字段名;字段名会先转小写并移除下划线、短横线。 + static const Set _sensitiveLogKeys = { + 'password', + 'passwd', + 'username', + 'accesstoken', + 'refreshtoken', + 'token', + 'authorization', + 'authcode', + 'paymentcode', + 'paycode', + 'qrcode', + 'servicecode', + 'shortcode', + 'cardid', + 'cardno', + 'cardnumber', + 'phone', + 'mobile', + 'idcard', + 'certno', + 'realname', + 'referenceimageurl', + 'imagehash', + 'localfilepath', + 'evidencefilepath', + }; + + /// 进程内唯一的 HTTP 管理器实例,确保拦截器和连接池不被重复创建。 static final HttpManage _instance = HttpManage._internal(); + /// 返回进程内共享的 HTTP 管理器实例。 factory HttpManage() => _instance; + /// 统一业务 Dio 实例;所有普通 API 请求必须复用该对象。 late Dio dio; + /// 创建 Dio、注册统一拦截器并配置项目既有的 Android HTTP 客户端策略。 HttpManage._internal() { BaseOptions globalOptions = BaseOptions( baseUrl: Constants.apiUrl, @@ -46,32 +93,54 @@ class HttpManage { "Bearer ${userInfo.accessToken}"; } } - options.headers["TENANT-ID"] = userInfo.tenantId ?? 1; + final tenantId = userInfo.tenantId; + // 后端会将 TENANT-ID 解析为 Long;旧版本若遗留非数值 code,先回退默认租户, + // 登录控制器随后会利用缓存租户表迁移为真实数值 id。 + options.headers["TENANT-ID"] = + tenantId != null && int.tryParse(tenantId) != null ? tenantId : 1; logger.i( - "|$_tag|请求开始 method:${options.method} url:${options.uri} " + "|$_tag|请求开始 method:${options.method} url:${_formatLogUri(options.uri)} " "query:${_formatLogData(options.queryParameters)} " "body:${_formatLogData(options.data)}", ); return handler.next(options); }, onResponse: (e, handler) async { + final silent = e.requestOptions.extra[silentRequestKey] == true; // 先判断是不是登录接口 late ServiceBase serviceData; if (e.requestOptions.path.contains(Constants.oauth2AppApiUrl)) { serviceData = ServiceBase(msg: "", code: 0, data: e.data); } else { - serviceData = ServiceBase.fromJson(e.data); + final parsed = _tryParseServiceBase(e.data); + if (parsed == null) { + _logResponse(options: e.requestOptions, response: e.data); + if (!silent) { + Toast.error(msg: "服务器返回格式异常,请稍后重试"); + EasyLoading.dismiss(); + } + handler.reject( + DioException( + requestOptions: e.requestOptions, + response: e, + type: DioExceptionType.badResponse, + error: '服务器返回格式不是统一 JSON 对象', + ), + ); + return; + } + serviceData = parsed; } if (serviceData.code == 401 && ObjectUtil.isNotEmpty(serviceData.msg)) { - if (!ignoreMsg.contains(serviceData.msg)) { + if (!silent && !ignoreMsg.contains(serviceData.msg)) { Toast.error(msg: serviceData.msg ?? "服务器未知错误"); } final isLogin = await _autoLogin(); if (!isLogin) { Get.offAndToNamed(RouteNames.login); } - } else if (serviceData.code != 0) { + } else if (serviceData.code != 0 && !silent) { Toast.error(msg: serviceData.msg ?? "服务器未知错误"); } // 续一下token @@ -82,36 +151,53 @@ class HttpManage { options: e.requestOptions, response: serviceData.data, ); + if (serviceData.code != 0 && + (silent || + e.requestOptions.extra[rejectBusinessErrorKey] == true)) { + handler.reject( + DioException( + requestOptions: e.requestOptions, + response: e, + type: DioExceptionType.badResponse, + error: serviceData.msg ?? '后台请求失败', + ), + ); + return; + } e.data = serviceData.data; handler.next(e); }, onError: (e, handler) async { + final silent = e.requestOptions.extra[silentRequestKey] == true; _logError(e); - if (e.type == DioExceptionType.connectionTimeout || + final timedOut = e.type == DioExceptionType.connectionTimeout || e.type == DioExceptionType.sendTimeout || - e.type == DioExceptionType.receiveTimeout) { + e.type == DioExceptionType.receiveTimeout; + if (!silent && timedOut) { Toast.error(msg: "网络请求超时"); EasyLoading.dismiss(); } final response = e.response; final statusCode = response?.statusCode; - if (statusCode != null) { - if (statusCode == 424) { - final isLogin = await _autoLogin(); - if (!isLogin) { - Get.offAndToNamed(RouteNames.login); - } - handler.next(e); + if (statusCode == 424 && + !e.requestOptions.path.contains(Constants.oauth2AppApiUrl)) { + final isLogin = await _autoLogin(); + if (!isLogin) { + Get.offAndToNamed(RouteNames.login); } } - if (response != null && response.data != null) { - ServiceBase data = ServiceBase.fromJson(response.data); - if (data.code != 0) { - Toast.error(msg: data.msg ?? "服务器未知错误"); - EasyLoading.dismiss(); - handler.next(e); - return; + + // 网关、代理或不存在的路由可能返回纯文本/HTML,不能直接强转 Map。 + final serviceError = _tryParseServiceBase(response?.data); + if (!silent && !timedOut && statusCode != null) { + final serviceMessage = serviceError?.msg; + if (ObjectUtil.isNotEmpty(serviceMessage) && + !ignoreMsg.contains(serviceMessage)) { + Toast.error(msg: serviceMessage!); + } else { + Toast.error(msg: _httpStatusMessage(statusCode)); } + EasyLoading.dismiss(); } return handler.next(e); }, @@ -128,6 +214,7 @@ class HttpManage { ); } + /// 使用本地保存的账号密码重新获取令牌;失败时清理失效登录信息并返回 false。 Future _autoLogin() async { final userSetting = await AppSettingTool.getUserSetting(); final sysSetting = await AppSettingTool.getSysSetting(); @@ -145,7 +232,9 @@ class HttpManage { }; try { final data = await UserApi.login(pa); - if (data != null) throw Error(); + if (data is! Map) { + throw const FormatException('自动登录响应不是 JSON 对象'); + } userSetting.userId = data['user_id']; userSetting.accessToken = data['access_token']; userSetting.refreshToken = data['refresh_token']; @@ -159,6 +248,8 @@ class HttpManage { return false; } + /// 在令牌接近过期时刷新并持久化新令牌;当前调用点保留但默认未启用。 + // ignore: unused_element Future _refreshToken() async { final userSetting = await AppSettingTool.getUserSetting(); if (ObjectUtil.isEmpty(userSetting.refreshToken) || @@ -192,22 +283,24 @@ class HttpManage { } } + /// 记录成功响应的请求耗时和脱敏后的响应摘要。 void _logResponse({ required RequestOptions options, dynamic response, }) { final cost = _calcRequestCost(options); logger.i( - "|$_tag|请求完成 method:${options.method} url:${options.uri} " + "|$_tag|请求完成 method:${options.method} url:${_formatLogUri(options.uri)} " "cost:${cost}ms response:${_formatLogData(response)}", ); } + /// 记录失败请求的耗时、错误类型和脱敏后的请求/响应摘要。 void _logError(DioException error) { final options = error.requestOptions; final cost = _calcRequestCost(options); logger.e( - "|$_tag|请求失败 method:${options.method} url:${options.uri} " + "|$_tag|请求失败 method:${options.method} url:${_formatLogUri(options.uri)} " "cost:${cost}ms type:${error.type.name} " "query:${_formatLogData(options.queryParameters)} " "body:${_formatLogData(options.data)} " @@ -216,6 +309,7 @@ class HttpManage { ); } + /// 根据请求开始时间计算本次请求耗时,缺少记录时返回 0 毫秒。 int _calcRequestCost(RequestOptions options) { final startAt = options.extra[_requestStartAtKey]; if (startAt is DateTime) { @@ -224,9 +318,93 @@ class HttpManage { return 0; } + /// 将统一业务响应安全解析为 ServiceBase;纯文本、HTML 或畸形 JSON 返回 null。 + ServiceBase? _tryParseServiceBase(dynamic value) { + if (value == null) return null; + try { + dynamic decoded = value; + if (value is String) { + decoded = jsonDecode(value); + } + if (decoded is! Map) return null; + return ServiceBase.fromJson( + decoded.map( + (key, item) => MapEntry(key.toString(), item), + ), + ); + } catch (_) { + return null; + } + } + + /// 将常见 HTTP 状态码转换为可操作的中文提示,不向界面回显服务端 HTML。 + String _httpStatusMessage(int statusCode) { + return switch (statusCode) { + 400 => '请求参数错误,请检查后重试', + 401 => '账号、密码或登录信息无效', + 403 => '当前账号没有访问权限', + 404 => '请求地址不存在(404),请检查服务配置', + 409 => '请求与当前数据状态冲突,请刷新后重试', + 424 => '登录状态已失效,请重新登录', + >= 500 => '服务器暂时不可用($statusCode),请稍后重试', + _ => '服务器请求失败($statusCode)', + }; + } + + /// 遮蔽 URL 查询参数中的账号、令牌和业务凭证,避免敏感值出现在原始日志文件。 + String _formatLogUri(Uri uri) { + if (!uri.hasQuery) return uri.toString(); + final queryParameters = uri.queryParameters.map( + (key, value) => MapEntry( + key, + _isSensitiveLogKey(key) ? '***' : value, + ), + ); + return uri.replace(queryParameters: queryParameters).toString(); + } + + /// 递归遮蔽 Map/List 中的敏感字段,保留非敏感结构用于问题定位。 + dynamic _redactLogData(dynamic data) { + if (data is FormData) { + return { + 'fields': { + for (final field in data.fields) + field.key: _isSensitiveLogKey(field.key) ? '***' : field.value, + }, + 'files': [ + for (final file in data.files) + { + 'field': file.key, + 'filename': file.value.filename ?? '未命名文件', + 'length': file.value.length, + }, + ], + }; + } + if (data is Map) { + return data.map( + (key, value) => MapEntry( + key, + _isSensitiveLogKey(key.toString()) ? '***' : _redactLogData(value), + ), + ); + } + if (data is Iterable) { + return data.map(_redactLogData).toList(growable: false); + } + return data; + } + + /// 判断字段名是否属于敏感日志字段,兼容 snake_case、kebab-case 与 camelCase。 + bool _isSensitiveLogKey(String key) { + final normalized = key.toLowerCase().replaceAll(RegExp(r'[_-]'), ''); + return _sensitiveLogKeys.contains(normalized); + } + + /// 将任意请求数据转换为有限长度的脱敏文本,供成功与失败日志统一调用。 String _formatLogData(dynamic data) { if (data == null) return "null"; - final text = data.toString(); + final text = sanitizeAppLogContent(_redactLogData(data).toString()); if (text.length <= _maxLogLength) return text; return "${text.substring(0, _maxLogLength)}...(已截断,原长度:${text.length})"; } diff --git a/lib/common/utils/manage/mq_manager.dart b/lib/common/utils/manage/mq_manager.dart index dbc834e..4dda38e 100644 --- a/lib/common/utils/manage/mq_manager.dart +++ b/lib/common/utils/manage/mq_manager.dart @@ -18,6 +18,9 @@ class MqManager { static MqttServerClient? mqttClient; static MqttConnectionState state = MqttConnectionState.disconnected; + /// 最近一次连接失败原因;连接成功或主动关闭后清空,供定时任务诊断展示。 + static String? connectionFailureReason; + // 当前设备的 topic static late String mainTopic; diff --git a/lib/common/utils/manage/print_manage.dart b/lib/common/utils/manage/print_manage.dart index 02ca50e..e33f28e 100644 --- a/lib/common/utils/manage/print_manage.dart +++ b/lib/common/utils/manage/print_manage.dart @@ -7,6 +7,9 @@ import 'package:pj_supermarket_cloud/common/index.dart'; class PrintManage extends BaseFacilityManage { static const String _tag = "打印机管理"; + /// 最近一次打印失败的摘要,供打印任务队列记录并展示。 + String? lastError; + USBPrinterManager? manager; CapabilityProfile? profile; int paperSizeWidthMM = PaperSizeWidth.mm80; @@ -21,6 +24,18 @@ class PrintManage extends BaseFacilityManage { @override String setName() => UsbFacilityNames.print.title; + /// 只发现 USB 设备,不建立打印连接,供设备状态探测任务使用。 + Future checkAvailable() async { + try { + final printers = await USBPrinterManager.discover(); + return printers.any((v) => _printVendorIds.contains(v.vendorId ?? "-1")) || + printers.length == 1; + } catch (e) { + logger.e("|$_tag|检查打印机失败error|$e|"); + return false; + } + } + @override Future open() async { final check = await super.open(); diff --git a/lib/common/utils/manage/serial_card_manage.dart b/lib/common/utils/manage/serial_card_manage.dart index dabee9a..53f627f 100644 --- a/lib/common/utils/manage/serial_card_manage.dart +++ b/lib/common/utils/manage/serial_card_manage.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:common_utils/common_utils.dart'; +import 'package:flutter_libserialport/flutter_libserialport.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; class SerialCardManage extends SerialBase { @@ -18,6 +19,16 @@ class SerialCardManage extends SerialBase { baudRate = 115200; } + /// 只检查串口节点是否存在,不打开串口,供设备状态探测任务使用。 + Future checkAvailable() async { + try { + return SerialPort.availablePorts.contains(portName); + } catch (e) { + logger.e("|读卡器|检查串口失败error|$e|"); + return false; + } + } + @override void readerClose() { super.readerClose(); diff --git a/lib/common/utils/manage/serial_steelyard_manage.dart b/lib/common/utils/manage/serial_steelyard_manage.dart index 8c881bc..070897e 100644 --- a/lib/common/utils/manage/serial_steelyard_manage.dart +++ b/lib/common/utils/manage/serial_steelyard_manage.dart @@ -1,3 +1,5 @@ +import 'package:flutter_libserialport/flutter_libserialport.dart'; + import 'package:pj_supermarket_cloud/common/index.dart'; class SerialSteelyardManage extends SerialBase { @@ -8,6 +10,16 @@ class SerialSteelyardManage extends SerialBase { readTimeout = 3000; } + /// 只检查串口节点是否存在,不打开串口,供设备状态探测任务使用。 + Future checkAvailable() async { + try { + return SerialPort.availablePorts.contains(portName); + } catch (e) { + logger.e("|称|检查串口失败error|$e|"); + return false; + } + } + double? parseWidget(List bytes) { String result = String.fromCharCodes(bytes); final RegExp regExp = RegExp(r'(\d+(\.\d+)?)'); diff --git a/lib/common/utils/plugs/face_discern_plugs.dart b/lib/common/utils/plugs/face_discern_plugs.dart index 9b51ce2..83cec4b 100644 --- a/lib/common/utils/plugs/face_discern_plugs.dart +++ b/lib/common/utils/plugs/face_discern_plugs.dart @@ -15,9 +15,27 @@ abstract class FaceDiscernPlugs { Constants.facePlugsEventChannel, ); + static const EventChannel _sessionEventChannel = EventChannel( + 'local/face_discern_session_event', + ); + + static const EventChannel _registrationEventChannel = EventChannel( + Constants.facePlugsRegistrationEventChannel, + ); + + /// 原生在库图片质量批量检测进度通道。 + static const EventChannel _qualityAuditEventChannel = EventChannel( + Constants.facePlugsQualityAuditEventChannel, + ); + static MethodChannel get getChannel => _channel; static Stream? _stream; + static Stream? _sessionStream; + static Stream? _registrationStream; + + /// 延迟创建并复用的在库图片质量检测进度流。 + static Stream? _qualityAuditStream; static Future jumpFaceDiscernPage() async { try { @@ -29,6 +47,128 @@ abstract class FaceDiscernPlugs { return false; } + static Future createPreviewTexture() async { + final map = await _channel.invokeMethod('createPreviewTexture'); + if (map is! Map) throw StateError('原生未返回有效的人脸预览纹理'); + return FaceRecognitionPreviewTexture.fromMap(map); + } + + static Future releasePreviewTexture(int textureId) async { + await _channel.invokeMethod('releasePreviewTexture', { + 'textureId': textureId, + }); + } + + static Future startRecognition({ + required String sessionId, + required FaceRecognitionScene scene, + }) { + return _invokeSession('startRecognition', { + 'sessionId': sessionId, + 'scene': scene.name, + }); + } + + static Future retryRecognition( + String sessionId, + ) { + return _invokeSession('retryRecognition', {'sessionId': sessionId}); + } + + static Future cancelRecognition( + String sessionId, { + String? message, + }) { + return _invokeSession('cancelRecognition', { + 'sessionId': sessionId, + 'message': message, + }); + } + + static Future finishRecognition( + String sessionId, + ) { + return _invokeSession('finishRecognition', {'sessionId': sessionId}); + } + + static Future suspendRecognition({ + String? reason, + }) { + return _invokeSession('suspendRecognition', {'reason': reason}); + } + + static Future getRecognitionState() { + return _invokeSession('getRecognitionState'); + } + + /// 通过 Android Camera 枚举检查终端是否存在摄像头,不打开设备也不占用识别资源。 + static Future hasCamera() async { + try { + return await _channel.invokeMethod('hasCamera') ?? false; + } catch (e) { + logger.w('|$_tag| hasCamera 错误: $e'); + return false; + } + } + + /// 清理 Android 私有目录中超过截止时间且未被 Floor 任务引用的证据图片。 + /// + /// [referencedPaths] 仅在进程内传给原生层做路径保护,不会写入日志或发送到服务端。 + static Future cleanupOrderEvidenceFiles({ + required Set referencedPaths, + required DateTime olderThan, + }) async { + return await _channel.invokeMethod('cleanupOrderEvidenceFiles', { + 'referencedPaths': referencedPaths.toList(growable: false), + 'olderThan': olderThan.millisecondsSinceEpoch, + }) ?? + 0; + } + + static Future getRecognizeConfiguration() { + return _invokeRecognizeConfiguration('getRecognizeConfiguration'); + } + + static Future setRecognizeConfiguration( + FaceRecognitionConfiguration configuration, + ) { + return _invokeRecognizeConfiguration( + 'setRecognizeConfiguration', + configuration.toMap(), + ); + } + + static Future resetRecognizeConfiguration() { + return _invokeRecognizeConfiguration('resetRecognizeConfiguration'); + } + + static Stream get sessionStream { + _sessionStream ??= _sessionEventChannel + .receiveBroadcastStream() + .where((event) => event is Map) + .map((event) => FaceRecognitionSessionEvent.fromMap(event as Map)) + .asBroadcastStream(); + return _sessionStream!; + } + + static Future _invokeSession( + String method, [ + Map? arguments, + ]) async { + final map = await _channel.invokeMethod(method, arguments); + if (map is! Map) throw StateError('原生未返回有效的人脸会话状态'); + return FaceRecognitionSessionEvent.fromMap(map); + } + + static Future _invokeRecognizeConfiguration( + String method, [ + Map? arguments, + ]) async { + final map = await _channel.invokeMethod(method, arguments); + if (map is! Map) throw StateError('原生未返回有效的人脸识别配置'); + return FaceRecognitionConfiguration.fromMap(map); + } + static Future getConfig() async { try { final Map? map = @@ -61,6 +201,124 @@ abstract class FaceDiscernPlugs { return false; } + /// 查询真实授权文件、引擎和内存人脸库状态。 + static Future getFaceStatus({ + required String appId, + required String sdkKey, + required String activeKey, + }) async { + try { + final map = await _channel.invokeMethod('getFaceStatus', { + 'appId': appId, + 'sdkKey': sdkKey, + 'activeKey': activeKey, + }); + return map is Map ? FacePluginStatus.fromMap(map) : null; + } catch (e) { + logger.w('|$_tag| getFaceStatus 错误: $e'); + return null; + } + } + + static Future activateFaceSdk({ + required String appId, + required String sdkKey, + required String activeKey, + }) async { + try { + final map = await _channel.invokeMethod('activateFaceSdk', { + 'appId': appId, + 'sdkKey': sdkKey, + 'activeKey': activeKey, + }); + if (map is Map) return FaceActivationResult.fromMap(map); + } catch (e) { + logger.w('|$_tag| activateFaceSdk 错误: $e'); + } + return const FaceActivationResult( + success: false, + code: -1, + message: '人脸插件激活失败,请稍后重试', + requiresPermission: false, + canOpenSettings: false, + ); + } + + static Future rebuildFaceLibrary({ + String? userId, + }) async { + final map = await _channel.invokeMethod('rebuildFaceLibrary', { + 'userId': userId, + }); + if (map is Map) return FaceRegistrationResult.fromMap(map); + return const FaceRegistrationResult( + success: false, + message: '人脸库注册失败,请稍后重试', + total: 0, + successCount: 0, + failedCount: 0, + failedSampleIds: [], + durationMs: 0, + ); + } + + /// 从临时图片提取注册特征,不修改虹软内存库和本地数据库。 + static Future extractFaceFeature({ + required String filePath, + }) async { + final map = await _channel.invokeMethod('extractFaceFeature', { + 'filePath': filePath, + }); + if (map is Map) return FaceFeatureExtractionResult.fromMap(map); + return const FaceFeatureExtractionResult( + success: false, + message: '原生未返回有效的人脸特征结果', + featureData: [], + ); + } + + /// 在原生人脸操作互斥边界内批量检测本地在库照片质量。 + /// + /// 文件路径仅传入本机 Android 插件;原生逐图解码并通过 [qualityAuditStream] + /// 报告进度,返回结果不会自动修改 Floor 数据库。 + static Future auditFaceQuality({ + required List items, + }) async { + final map = await _channel.invokeMethod('auditFaceQuality', { + 'items': items.map((item) => item.toMap()).toList(growable: false), + }); + if (map is Map) return FaceQualityAuditResult.fromMap(map); + return const FaceQualityAuditResult( + results: [], + message: '原生未返回有效的质量检测结果', + ); + } + + /// 在虹软内存库中原子应用单样本新增、替换或删除。 + /// + /// 替换失败时原生层恢复旧特征;[operation] 仅允许 upsert 或 delete。 + static Future applyFaceDelta({ + required String operation, + required String sampleId, + String? userId, + String? username, + List? featureData, + }) async { + final map = await _channel.invokeMethod('applyFaceDelta', { + 'operation': operation, + 'sampleId': int.tryParse(sampleId), + 'userId': userId, + 'username': username, + 'featureData': featureData, + }); + if (map is Map) return FaceDeltaApplyResult.fromMap(map); + return const FaceDeltaApplyResult( + success: false, + message: '原生未返回有效的人脸增量结果', + code: -99, + ); + } + static Future registerAllStorageFace({ required String filePath, String? fileName, @@ -126,4 +384,22 @@ abstract class FaceDiscernPlugs { }); return _stream!; } + + static Stream get registrationStream { + _registrationStream ??= _registrationEventChannel + .receiveBroadcastStream() + .where((event) => event is Map) + .map((event) => FaceRegistrationProgress.fromMap(event as Map)); + return _registrationStream!; + } + + /// 在库人脸质量检测进度广播流;订阅时会先收到原生最新状态。 + static Stream get qualityAuditStream { + _qualityAuditStream ??= _qualityAuditEventChannel + .receiveBroadcastStream() + .where((event) => event is Map) + .map((event) => FaceQualityAuditProgress.fromMap(event as Map)) + .asBroadcastStream(); + return _qualityAuditStream!; + } } diff --git a/lib/common/utils/sync/file_upload_record_tool.dart b/lib/common/utils/sync/file_upload_record_tool.dart new file mode 100644 index 0000000..8743fb5 --- /dev/null +++ b/lib/common/utils/sync/file_upload_record_tool.dart @@ -0,0 +1,191 @@ +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 文件上传记录的结构化存储入口。 +abstract final class FileUploadRecordTool { + /// 终端日志在通用文件上传记录表中的稳定业务类型。 + static const String terminalLogBizType = 'terminal_log'; + + /// 当前应用数据库中的文件上传记录 DAO。 + static FileUploadRecordDao get _dao => + Get.find().dataBase.getFileUploadRecordDao; + + /// 将 v8 之前保存在 sysJson 的成功状态一次性导入 file_upload_record。 + /// + /// 相同设备、业务类型和相对路径已有记录时保持结构化表数据,不用旧 JSON 覆盖。 + static Future importLegacy( + String hardId, + Map states, + ) async { + final now = DateTime.now(); + for (final state in states.values) { + if (await _dao.getByPath( + hardId, + terminalLogBizType, + state.relativePath, + ) != + null) { + continue; + } + await _dao.save( + FileUploadRecordEntity( + hardId: hardId, + bizType: terminalLogBizType, + relativePath: state.relativePath, + originalName: _fileName(state.relativePath), + sourceSize: state.sourceSize, + sourceModifiedTime: state.sourceModifiedTime, + sanitizedHash: state.sanitizedHash, + sanitizedSize: 0, + status: FileUploadStatus.success.name, + uploadedAt: state.uploadedAt, + createTime: state.uploadedAt, + updateTime: now, + ), + ); + } + } + + /// 查询指定设备所有成功的终端日志状态,并以相对路径构建快速匹配映射。 + static Future> getSuccessfulStates( + String hardId, + ) async { + final result = {}; + for (final record in await _dao.getAll(hardId, terminalLogBizType)) { + if (record.status != FileUploadStatus.success.name || + record.uploadedAt == null) { + continue; + } + result[record.relativePath] = TerminalLogUploadState( + relativePath: record.relativePath, + sourceSize: record.sourceSize, + sourceModifiedTime: record.sourceModifiedTime, + sanitizedHash: record.sanitizedHash, + uploadedAt: record.uploadedAt!, + ); + } + return result; + } + + /// 创建或覆盖同一路径的 uploading 状态,并将尝试次数递增后返回带主键实体。 + static Future beginAttempt({ + required String hardId, + required String relativePath, + required int sourceSize, + required DateTime sourceModifiedTime, + }) async { + final previous = await _dao.getByPath( + hardId, + terminalLogBizType, + relativePath, + ); + final now = DateTime.now(); + final next = FileUploadRecordEntity( + id: previous?.id, + hardId: hardId, + bizType: terminalLogBizType, + relativePath: relativePath, + originalName: _fileName(relativePath), + sourceSize: sourceSize, + sourceModifiedTime: sourceModifiedTime, + sanitizedHash: previous?.sanitizedHash ?? '', + sanitizedSize: previous?.sanitizedSize ?? 0, + status: FileUploadStatus.uploading.name, + serverFileId: previous?.serverFileId, + attemptCount: (previous?.attemptCount ?? 0) + 1, + lastAttemptAt: now, + uploadedAt: previous?.uploadedAt, + createTime: previous?.createTime ?? now, + updateTime: now, + ); + final id = await _dao.save(next); + return FileUploadRecordEntity( + id: id, + hardId: next.hardId, + bizType: next.bizType, + relativePath: next.relativePath, + originalName: next.originalName, + sourceSize: next.sourceSize, + sourceModifiedTime: next.sourceModifiedTime, + sanitizedHash: next.sanitizedHash, + sanitizedSize: next.sanitizedSize, + status: next.status, + serverFileId: next.serverFileId, + attemptCount: next.attemptCount, + lastAttemptAt: next.lastAttemptAt, + uploadedAt: next.uploadedAt, + createTime: next.createTime, + updateTime: next.updateTime, + ); + } + + /// 将一次上传尝试标记为成功,并保存脱敏文件哈希、大小和后端记录 ID。 + static Future markSuccess({ + required FileUploadRecordEntity attempt, + required String sanitizedHash, + required int sanitizedSize, + String? serverFileId, + }) async { + final now = DateTime.now(); + await _dao.save( + FileUploadRecordEntity( + id: attempt.id, + hardId: attempt.hardId, + bizType: attempt.bizType, + relativePath: attempt.relativePath, + originalName: attempt.originalName, + sourceSize: attempt.sourceSize, + sourceModifiedTime: attempt.sourceModifiedTime, + sanitizedHash: sanitizedHash, + sanitizedSize: sanitizedSize, + status: FileUploadStatus.success.name, + serverFileId: serverFileId ?? attempt.serverFileId, + attemptCount: attempt.attemptCount, + lastAttemptAt: attempt.lastAttemptAt, + uploadedAt: now, + createTime: attempt.createTime, + updateTime: now, + ), + ); + } + + /// 将一次上传尝试标记为失败,保留上次成功时间和服务端 ID 供后续重试使用。 + static Future markFailure({ + required FileUploadRecordEntity attempt, + required Object error, + }) async { + final now = DateTime.now(); + await _dao.save( + FileUploadRecordEntity( + id: attempt.id, + hardId: attempt.hardId, + bizType: attempt.bizType, + relativePath: attempt.relativePath, + originalName: attempt.originalName, + sourceSize: attempt.sourceSize, + sourceModifiedTime: attempt.sourceModifiedTime, + sanitizedHash: attempt.sanitizedHash, + sanitizedSize: attempt.sanitizedSize, + status: FileUploadStatus.failed.name, + serverFileId: attempt.serverFileId, + attemptCount: attempt.attemptCount, + lastAttemptAt: attempt.lastAttemptAt, + uploadedAt: attempt.uploadedAt, + lastError: _safeError(error), + createTime: attempt.createTime, + updateTime: now, + ), + ); + } + + /// 从跨平台相对路径中提取不含目录的原始文件名。 + static String _fileName(String path) => + path.replaceAll('\\', '/').split('/').last; + + /// 将上传异常摘要限制在 500 个字符内,避免单条状态记录无限增长。 + static String _safeError(Object error) { + final value = error.toString(); + return value.length <= 500 ? value : '${value.substring(0, 500)}...'; + } +} diff --git a/lib/common/utils/sync/order_evidence_upload_tool.dart b/lib/common/utils/sync/order_evidence_upload_tool.dart new file mode 100644 index 0000000..38afb9b --- /dev/null +++ b/lib/common/utils/sync/order_evidence_upload_tool.dart @@ -0,0 +1,857 @@ +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:get/get.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 订单识别证据本地队列入口。 +/// +/// 本工具只保存短期待办和失败元数据,长期证据由服务端 `hard_order_evidence_image` 保存。 +/// 所有批处理均串行执行,避免与收银、人脸识别及其他重型任务争用磁盘和上行带宽。 +abstract final class OrderEvidenceUploadTool { + /// 单轮自动或手动补传最多处理的任务数量。 + static const int batchLimit = 20; + + /// 尚未发起订单的识别临时任务最长保留时间。 + static const Duration waitingOrderRetention = Duration(hours: 24); + + /// 已绑定订单但未成功的现场图片最长保留时间。 + static const Duration failedImageRetention = Duration(days: 30); + + /// 成功任务元数据保留时间,超过后只删除本地队列记录。 + static const Duration successfulMetadataRetention = Duration(days: 30); + + /// 服务端允许继续补传的已支付、退款及已核销订单状态编码。 + static const Set uploadableOrderStatuses = {'5', '10', '20'}; + + /// 退避阶梯,依次为 1、5、15、60、120、240、360 分钟,之后固定最长 6 小时。 + static const List retryDelays = [ + Duration(minutes: 1), + Duration(minutes: 5), + Duration(minutes: 15), + Duration(minutes: 60), + Duration(minutes: 120), + Duration(minutes: 240), + Duration(minutes: 360), + ]; + + /// 当前应用数据库中的订单证据任务 DAO;调度启动前数据库必须已经由 AppControl 打开。 + static OrderEvidenceUploadTaskDao get _dao => + Get.find().dataBase.getOrderEvidenceUploadTaskDao; + + /// 当前正在执行的补传批次;预约核销即时触发与五分钟定时任务共用,防止并发处理同一现场文件。 + static Future? _processingBatch; + + /// 保存一次收银场景人脸成功结果对应的参考图和现场图待办。 + /// + /// 同一识别会话重复回调时直接复用已有记录。现场文件生成失败仍会建立缺失任务,待订单 + /// 成功后向服务端明确报告 `UNAVAILABLE`,避免 PC 端把异常误判为没有使用人脸识别。 + static Future createFaceRecognitionTasks({ + required FaceStream recognition, + required FaceEntity face, + }) async { + final sessionId = recognition.sessionId?.trim(); + if (sessionId == null || sessionId.isEmpty) return; + await _cleanupOrphanEvidenceFiles(DateTime.now()); + if ((await _dao.getByRecognitionSession(sessionId)).isNotEmpty) return; + + final now = DateTime.now(); + final capturedAt = recognition.capturedAt == null + ? now + : DateTime.fromMillisecondsSinceEpoch(recognition.capturedAt!); + final package = await PackageInfo.fromPlatform(); + final appVersion = '${package.version}+${package.buildNumber}'; + final expiresAt = now.add(waitingOrderRetention); + final common = ( + sessionId: sessionId, + userId: face.userId, + sampleId: face.sampleId, + similarity: recognition.similar, + threshold: recognition.similarityThreshold, + capturedAt: capturedAt, + appVersion: appVersion, + now: now, + expiresAt: expiresAt, + ); + + await _dao.save(OrderEvidenceUploadTaskEntity( + recognitionSessionId: common.sessionId, + evidenceType: OrderEvidenceType.faceRecognition.code, + evidenceRole: OrderEvidenceRole.faceReference.code, + sequenceNo: 0, + userId: common.userId, + faceSampleId: common.sampleId, + referenceImageUrl: face.imagePath, + referenceImageVersion: face.updateTime?.toIso8601String(), + similarity: common.similarity, + similarityThreshold: common.threshold, + capturedAt: common.capturedAt, + appVersion: common.appVersion, + status: OrderEvidenceTaskStatus.waitingOrder.code, + createTime: common.now, + updateTime: common.now, + expiresAt: common.expiresAt, + )); + await _dao.save(OrderEvidenceUploadTaskEntity( + recognitionSessionId: common.sessionId, + evidenceType: OrderEvidenceType.faceRecognition.code, + evidenceRole: OrderEvidenceRole.faceLive.code, + sequenceNo: 0, + userId: common.userId, + faceSampleId: common.sampleId, + localFilePath: recognition.evidenceFilePath, + imageHash: recognition.evidenceHash, + imageSize: recognition.evidenceSize, + imageWidth: recognition.evidenceWidth, + imageHeight: recognition.evidenceHeight, + similarity: common.similarity, + similarityThreshold: common.threshold, + capturedAt: common.capturedAt, + appVersion: common.appVersion, + status: OrderEvidenceTaskStatus.waitingOrder.code, + lastError: recognition.evidenceFilePath == null + ? _safeFailure(recognition.evidenceError, fallback: '现场人脸图片生成失败') + : null, + createTime: common.now, + updateTime: common.now, + expiresAt: common.expiresAt, + )); + } + + /// 将识别会话绑定到即将提交的终端订单号,并返回下单接口使用的元数据清单。 + /// + /// 绑定只表示订单请求已经发起,不表示服务端已落单或支付成功;服务端 ID 必须在响应成功或 + /// 后台对账后再写入。若同一订单位置已有任务,保留先到记录并取消重复现场文件。 + static Future>> bindSessionToOrder({ + required String recognitionSessionId, + required String androidOrderCode, + }) async { + final tasks = await _dao.getByRecognitionSession(recognitionSessionId); + if (tasks.isEmpty) return const []; + final now = DateTime.now(); + final bound = []; + for (final task in tasks) { + if (_isTerminal(OrderEvidenceTaskStatus.fromCode(task.status))) continue; + final existing = await _dao.getByPosition( + androidOrderCode, + task.evidenceType, + task.evidenceRole, + task.sequenceNo, + ); + if (existing != null && existing.id != task.id) { + await _deleteLocalFile(task.localFilePath); + await _dao.edit(task.copyWith( + status: OrderEvidenceTaskStatus.cancelled.code, + clearLocalFilePath: true, + lastError: '同一订单证据位置已有待办,本记录已取消', + updateTime: now, + )); + bound.add(existing); + continue; + } + final next = task.copyWith( + androidOrderCode: androidOrderCode, + updateTime: now, + expiresAt: now.add(failedImageRetention), + ); + await _dao.edit(next); + bound.add(next); + } + return bound.map(_toCreateOrderMetadata).toList(growable: false); + } + + /// 在预约核销请求发出前,把识别会话预关联到已经存在的服务端订单。 + /// + /// 预关联仍保持 `WAITING_ORDER`,只有核销成功后才切换为可上传状态;即使核销响应丢失, + /// 后台任务也能等订单归属变更到当前设备后继续对账。重复选择同一订单时复用原任务。 + static Future prepareSessionForExistingOrder({ + required String recognitionSessionId, + required String serverOrderId, + }) async { + final tasks = await _dao.getByRecognitionSession(recognitionSessionId); + if (tasks.isEmpty) throw StateError('未找到本次人脸识别图片,请重新识别'); + final now = DateTime.now(); + for (final task in tasks) { + if (_isTerminal(OrderEvidenceTaskStatus.fromCode(task.status))) continue; + final existing = await _dao.getByServerOrderPosition( + serverOrderId, + task.evidenceType, + task.evidenceRole, + task.sequenceNo, + ); + if (existing != null && existing.id != task.id) { + await _deleteLocalFile(task.localFilePath); + await _dao.edit(task.copyWith( + status: OrderEvidenceTaskStatus.cancelled.code, + clearLocalFilePath: true, + lastError: '同一预约订单证据位置已有待办,本记录已取消', + updateTime: now, + )); + continue; + } + await _dao.edit(task.copyWith( + serverOrderId: serverOrderId, + updateTime: now, + )); + } + } + + /// 返回识别会话产生任务中的应用版本,供下单 DTO 顶层 `appVersion` 使用。 + static Future getSessionAppVersion( + String recognitionSessionId) async { + final tasks = await _dao.getByRecognitionSession(recognitionSessionId); + return tasks.isEmpty ? null : tasks.first.appVersion; + } + + /// 服务端明确返回订单成功后写入订单 ID,并把活动任务切换为可补传状态。 + static Future markOrderReady({ + required String recognitionSessionId, + required String serverOrderId, + }) async { + final now = DateTime.now(); + for (final task + in await _dao.getByRecognitionSession(recognitionSessionId)) { + final status = OrderEvidenceTaskStatus.fromCode(task.status); + if (_isTerminal(status)) continue; + await _dao.edit(task.copyWith( + serverOrderId: serverOrderId, + status: OrderEvidenceTaskStatus.ready.code, + clearNextRetryAt: true, + clearLastError: true, + updateTime: now, + expiresAt: now.add(failedImageRetention), + )); + } + } + + /// 订单明确失败或用户在下单前取消时终止识别会话任务,并删除现场临时图片。 + static Future cancelSession( + String recognitionSessionId, { + String reason = '订单未提交,证据任务已取消', + }) async { + final now = DateTime.now(); + for (final task + in await _dao.getByRecognitionSession(recognitionSessionId)) { + if (_isTerminal(OrderEvidenceTaskStatus.fromCode(task.status))) continue; + await _deleteLocalFile(task.localFilePath); + await _dao.edit(task.copyWith( + status: OrderEvidenceTaskStatus.cancelled.code, + clearLocalFilePath: true, + lastError: _safeFailure(reason), + clearNextRetryAt: true, + updateTime: now, + )); + } + } + + /// 串行处理一批到期任务,并在业务处理前完成 24 小时、30 天和成功元数据清理。 + static Future processPending() async { + final running = _processingBatch; + if (running != null) return running; + final batch = _processPendingBatch(); + _processingBatch = batch; + try { + return await batch; + } finally { + if (identical(_processingBatch, batch)) _processingBatch = null; + } + } + + /// 执行单个真实补传批次;调用方必须通过 [processPending] 取得进程内串行保护。 + static Future _processPendingBatch() async { + final now = DateTime.now(); + await _cleanupExpired(now); + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) { + throw const ScheduledTaskDependencyException('终端尚未绑定,无法补传订单证据'); + } + var scanned = 0; + var skipped = 0; + var succeeded = 0; + var failed = 0; + + final due = await _dao.getDueTasks(now, batchLimit); + for (final task in due) { + scanned++; + final result = await _processTask(hardId, task); + switch (result) { + case _TaskProcessResult.skipped: + skipped++; + break; + case _TaskProcessResult.succeeded: + succeeded++; + break; + case _TaskProcessResult.failed: + failed++; + break; + } + } + + final remaining = (batchLimit - scanned).clamp(0, batchLimit); + if (remaining > 0) { + final reports = await _dao.getUnavailableReportTasks(now, remaining); + for (final task in reports) { + scanned++; + final reported = await _reportUnavailable(hardId, task); + if (reported) { + succeeded++; + } else { + failed++; + } + } + } + return OrderEvidenceUploadSummary( + scanned: scanned, + skipped: skipped, + succeeded: succeeded, + failed: failed, + ); + } + + /// 返回指定尝试次数对应的退避时长;超出阶梯后固定为 6 小时。 + static Duration retryDelayForAttempt(int attemptCount) { + final index = (attemptCount - 1).clamp(0, retryDelays.length - 1); + return retryDelays[index]; + } + + /// 判断下单失败是否仍可能已经在服务端落单,需要保留证据并按终端订单号对账。 + /// + /// 请求超时、断连、取消以及 5xx 都可能发生在服务端提交事务之后;明确的 4xx/业务拒绝和 + /// 证书失败则没有继续对账的必要。非 Dio 异常无法证明服务端未落单,按保守策略保留。 + static bool shouldReconcileOrderFailure(Object error) { + if (error is! DioException) return true; + return switch (error.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout || + DioExceptionType.connectionError || + DioExceptionType.cancel || + DioExceptionType.unknown => + true, + DioExceptionType.badCertificate => false, + DioExceptionType.badResponse => + (error.response?.statusCode ?? 500) >= 500, + }; + } + + /// 处理单条活动任务,必要时先按终端订单号对账再提交参考图或上传现场图。 + static Future<_TaskProcessResult> _processTask( + String hardId, + OrderEvidenceUploadTaskEntity source, + ) async { + var task = source; + if (!_hasOrderReference(task)) { + return _TaskProcessResult.skipped; + } + try { + final resolved = await HardApi.resolveOrderEvidence( + hardId: hardId, + androidOrderCode: task.androidOrderCode, + orderId: task.serverOrderId, + ); + if (resolved == null) throw StateError('订单对账结果为空'); + if (!uploadableOrderStatuses.contains(resolved.orderStatus)) { + await _scheduleRetry(task, '订单尚未确认支付'); + return _TaskProcessResult.skipped; + } + final serverEvidence = _findServerEvidence(resolved, task); + if (serverEvidence?.status == 'AVAILABLE') { + if (task.imageHash != null && + serverEvidence?.imageHash != null && + task.imageHash!.toLowerCase() != + serverEvidence!.imageHash!.toLowerCase()) { + await _markPermanent( + task, + '服务端相同位置已存在其他图片哈希', + serverOrderId: resolved.orderId, + evidenceId: serverEvidence.id, + // AVAILABLE 记录不允许被 UNAVAILABLE 覆盖;此标记表示本地无需再发缺失报告。 + unavailableReported: true, + ); + return _TaskProcessResult.failed; + } + await _markSuccess( + task, + serverOrderId: resolved.orderId, + evidenceId: serverEvidence!.id, + imageUrl: serverEvidence.imageUrl, + ); + return _TaskProcessResult.succeeded; + } + if (serverEvidence?.status == 'UNAVAILABLE') { + await _markPermanent( + task, + serverEvidence?.failureReason ?? '服务端已记录证据永久缺失', + serverOrderId: resolved.orderId, + evidenceId: serverEvidence?.id, + unavailableReported: true, + ); + return _TaskProcessResult.failed; + } + task = task.copyWith( + serverOrderId: resolved.orderId, + status: OrderEvidenceTaskStatus.ready.code, + clearNextRetryAt: true, + updateTime: DateTime.now(), + expiresAt: DateTime.now().add(failedImageRetention), + ); + await _dao.edit(task); + + if (task.evidenceRole == OrderEvidenceRole.faceLive.code) { + final path = task.localFilePath; + if (path == null || !await File(path).exists()) { + task = await _markPermanent( + task, + task.lastError ?? '终端现场图片文件已丢失', + serverOrderId: resolved.orderId, + ); + return await _reportUnavailable(hardId, task) + ? _TaskProcessResult.succeeded + : _TaskProcessResult.failed; + } + await _dao.edit(task.copyWith( + status: OrderEvidenceTaskStatus.uploading.code, + updateTime: DateTime.now(), + )); + final result = await HardApi.uploadOrderEvidence( + hardId: hardId, + request: _toRequest(task), + filePath: path, + ); + return await _applyServiceResult(task, result); + } + + final result = await HardApi.submitOrderEvidence( + hardId: hardId, + request: _toRequest(task, status: 'PENDING'), + ); + return await _applyServiceResult(task, result); + } catch (error) { + // 预约核销请求发出前已预关联服务端订单,但订单只有核销提交后才归属当前设备。 + // 这里把核销前的归属拒绝视为可重试,避免请求响应丢失后永久丢掉已经采集的现场图片。 + if (_isPreparedReservationEvidence(task)) { + await _schedulePreparedReservationRetry( + task, + _safeFailureForError(error), + ); + } else if (_isPermanentError(error)) { + await _markPermanent(task, _safeFailureForError(error)); + } else { + await _scheduleRetry(task, _safeFailureForError(error)); + } + return _TaskProcessResult.failed; + } + } + + /// 把服务端幂等结果转换为本地终态或下一轮重试状态。 + static Future<_TaskProcessResult> _applyServiceResult( + OrderEvidenceUploadTaskEntity task, + ServiceOrderEvidenceUploadResult? result, + ) async { + if (result == null) { + await _scheduleRetry(task, '服务端证据处理结果为空'); + return _TaskProcessResult.failed; + } + if (result.status == 'AVAILABLE') { + await _markSuccess( + task, + serverOrderId: result.orderId, + evidenceId: result.evidenceId, + imageUrl: result.imageUrl, + ); + return _TaskProcessResult.succeeded; + } + if (result.status == 'UNAVAILABLE') { + await _markPermanent( + task, + result.message, + serverOrderId: result.orderId, + evidenceId: result.evidenceId, + unavailableReported: true, + ); + return _TaskProcessResult.failed; + } + await _scheduleRetry(task, result.message); + return _TaskProcessResult.failed; + } + + /// 向服务端报告现场图片已经永久缺失;尚未取得订单 ID 时先执行一次订单对账。 + static Future _reportUnavailable( + String hardId, + OrderEvidenceUploadTaskEntity source, + ) async { + var task = source; + try { + if (task.serverOrderId == null) { + final resolved = await HardApi.resolveOrderEvidence( + hardId: hardId, + androidOrderCode: task.androidOrderCode, + orderId: task.serverOrderId, + ); + if (resolved == null || + !uploadableOrderStatuses.contains(resolved.orderStatus)) { + await _scheduleUnavailableRetry(task, '永久缺失上报等待订单支付确认'); + return false; + } + task = task.copyWith( + serverOrderId: resolved.orderId, + updateTime: DateTime.now(), + ); + await _dao.edit(task); + } + final result = await HardApi.submitOrderEvidence( + hardId: hardId, + request: _toRequest( + task, + status: 'UNAVAILABLE', + failureReason: task.lastError ?? '终端证据图片永久缺失', + ), + ); + if (result?.status == 'AVAILABLE') { + await _markSuccess( + task, + serverOrderId: result!.orderId, + evidenceId: result.evidenceId, + imageUrl: result.imageUrl, + ); + return true; + } + if (result == null || result.status != 'UNAVAILABLE') { + await _scheduleUnavailableRetry( + task, + result?.message ?? '永久缺失上报结果为空', + ); + return false; + } + await _dao.edit(task.copyWith( + serverOrderId: result.orderId, + serverEvidenceId: result.evidenceId, + status: OrderEvidenceTaskStatus.fromCode(task.status) == + OrderEvidenceTaskStatus.expired + ? OrderEvidenceTaskStatus.expired.code + : OrderEvidenceTaskStatus.permanentFailed.code, + unavailableReportedAt: DateTime.now(), + clearNextRetryAt: true, + updateTime: DateTime.now(), + )); + return true; + } catch (error) { + await _scheduleUnavailableRetry(task, _safeFailureForError(error)); + return false; + } + } + + /// 清理未绑定和图片到期任务,并删除超过保留期的成功元数据。 + static Future _cleanupExpired(DateTime now) async { + for (final task in await _dao.getExpiredWaitingTasks(now)) { + await _deleteLocalFile(task.localFilePath); + await _dao.edit(task.copyWith( + status: OrderEvidenceTaskStatus.expired.code, + clearLocalFilePath: true, + lastError: task.androidOrderCode == null + ? '识别后 24 小时内未发起有效订单' + : '订单证据超过本地保留期限仍未完成', + clearNextRetryAt: true, + updateTime: now, + )); + } + for (final task in await _dao.getExpiredImageTasks(now)) { + await _deleteLocalFile(task.localFilePath); + await _dao.edit(task.copyWith( + status: OrderEvidenceTaskStatus.expired.code, + clearLocalFilePath: true, + lastError: '现场图片保留 30 天后仍未补传成功', + clearNextRetryAt: true, + updateTime: now, + )); + } + await _dao + .deleteSuccessfulBefore(now.subtract(successfulMetadataRetention)); + await _cleanupOrphanEvidenceFiles(now); + } + + /// 删除原生写图成功但尚未来得及落 Floor 队列就崩溃留下的无主文件。 + /// + /// 原生层只会删除超过 24 小时且不在任一本地任务 `localFilePath` 中的 JPEG;仍在等待订单、 + /// 重试或上传中的文件会被保护,并继续遵循绑定后最多 30 天的保留策略。 + static Future _cleanupOrphanEvidenceFiles(DateTime now) async { + try { + final referencedPaths = (await _dao.getAll()) + .map((task) => task.localFilePath) + .whereType() + .where((path) => path.isNotEmpty) + .toSet(); + await FaceDiscernPlugs.cleanupOrderEvidenceFiles( + referencedPaths: referencedPaths, + olderThan: now.subtract(waitingOrderRetention), + ); + } catch (error) { + logger.w( + '|订单证据补传|无主现场图片清理失败|errorType:${error.runtimeType}|', + ); + } + } + + /// 将成功任务切换为终态并立即删除现场图片,文件删除失败也不反向改变服务端成功结果。 + static Future _markSuccess( + OrderEvidenceUploadTaskEntity task, { + required String serverOrderId, + required String evidenceId, + String? imageUrl, + }) async { + await _deleteLocalFile(task.localFilePath); + final now = DateTime.now(); + await _dao.edit(task.copyWith( + serverOrderId: serverOrderId, + status: OrderEvidenceTaskStatus.success.code, + serverEvidenceId: evidenceId, + serverImageUrl: imageUrl, + clearLocalFilePath: true, + clearNextRetryAt: true, + clearLastError: true, + completedAt: now, + updateTime: now, + )); + } + + /// 将确定无法恢复的任务保存为永久失败,可选标记服务端已经收到永久缺失状态。 + static Future _markPermanent( + OrderEvidenceUploadTaskEntity task, + String reason, { + String? serverOrderId, + String? evidenceId, + bool unavailableReported = false, + }) async { + final next = task.copyWith( + serverOrderId: serverOrderId, + serverEvidenceId: evidenceId, + status: OrderEvidenceTaskStatus.permanentFailed.code, + lastError: _safeFailure(reason), + unavailableReportedAt: unavailableReported ? DateTime.now() : null, + clearNextRetryAt: true, + updateTime: DateTime.now(), + ); + await _dao.edit(next); + return next; + } + + /// 保存一次失败及下一次允许执行时间,不抛出原始异常或记录终端完整文件路径。 + static Future _scheduleRetry( + OrderEvidenceUploadTaskEntity task, + String reason, + ) async { + final attempts = task.attemptCount + 1; + final now = DateTime.now(); + await _dao.edit(task.copyWith( + status: OrderEvidenceTaskStatus.retryWait.code, + attemptCount: attempts, + nextRetryAt: now.add(retryDelayForAttempt(attempts)), + lastError: _safeFailure(reason), + updateTime: now, + )); + } + + /// 预约核销尚未确认时保留等待状态并设置退避,避免设备归属尚未更新被误判为永久失败。 + static Future _schedulePreparedReservationRetry( + OrderEvidenceUploadTaskEntity task, + String reason, + ) async { + final attempts = task.attemptCount + 1; + final now = DateTime.now(); + await _dao.edit(task.copyWith( + status: OrderEvidenceTaskStatus.waitingOrder.code, + attemptCount: attempts, + nextRetryAt: now.add(retryDelayForAttempt(attempts)), + lastError: _safeFailure(reason), + updateTime: now, + )); + } + + /// 永久缺失上报失败时只更新退避时间,保持 EXPIRED 或 PERMANENT_FAILED 终态不再进入上传队列。 + static Future _scheduleUnavailableRetry( + OrderEvidenceUploadTaskEntity task, + String reason, + ) async { + final attempts = task.attemptCount + 1; + final now = DateTime.now(); + final current = OrderEvidenceTaskStatus.fromCode(task.status); + await _dao.edit(task.copyWith( + status: current == OrderEvidenceTaskStatus.expired + ? OrderEvidenceTaskStatus.expired.code + : OrderEvidenceTaskStatus.permanentFailed.code, + attemptCount: attempts, + nextRetryAt: now.add(retryDelayForAttempt(attempts)), + lastError: _safeFailure(reason), + updateTime: now, + )); + } + + /// 查找服务端同一类型、角色和序号的证据位置。 + static ServiceOrderEvidenceImage? _findServerEvidence( + ServiceOrderEvidenceResolve resolved, + OrderEvidenceUploadTaskEntity task, + ) { + for (final evidence in resolved.evidenceImages) { + if (evidence.evidenceType == task.evidenceType && + evidence.evidenceRole == task.evidenceRole && + evidence.sequenceNo == task.sequenceNo) { + return evidence; + } + } + return null; + } + + /// 构造提交或上传接口共用请求,终端本地路径永远不会进入请求字段。 + static OrderEvidenceRequest _toRequest( + OrderEvidenceUploadTaskEntity task, { + String? status, + String? failureReason, + }) { + return OrderEvidenceRequest( + androidOrderCode: task.androidOrderCode, + orderId: task.serverOrderId, + evidenceType: task.evidenceType, + evidenceRole: task.evidenceRole, + sequenceNo: task.sequenceNo, + userId: task.userId, + faceSampleId: task.faceSampleId, + referenceImageUrl: task.referenceImageUrl, + referenceImageVersion: + DateTime.tryParse(task.referenceImageVersion ?? ''), + similarity: task.similarity, + similarityThreshold: task.similarityThreshold, + capturedAt: task.capturedAt, + imageHash: task.imageHash, + imageSize: task.imageSize, + imageWidth: task.imageWidth, + imageHeight: task.imageHeight, + status: status, + failureReason: failureReason, + appVersion: task.appVersion, + ); + } + + /// 构造下单 DTO 的可选证据占位元数据,不包含本地文件路径和参考图版本内部字段。 + static Map _toCreateOrderMetadata( + OrderEvidenceUploadTaskEntity task, + ) { + return { + 'evidenceType': task.evidenceType, + 'evidenceRole': task.evidenceRole, + 'sequenceNo': task.sequenceNo, + 'userId': task.userId, + if (task.faceSampleId != null) 'faceSampleId': task.faceSampleId, + if (task.referenceImageUrl != null) + 'referenceImageUrl': task.referenceImageUrl, + if (task.referenceImageVersion != null) + 'referenceImageVersion': task.referenceImageVersion, + if (task.similarity != null) 'similarity': task.similarity, + if (task.similarityThreshold != null) + 'similarityThreshold': task.similarityThreshold, + 'capturedAt': task.capturedAt.toIso8601String(), + if (task.imageHash != null) 'imageHash': task.imageHash, + if (task.imageSize != null) 'imageSize': task.imageSize, + if (task.imageWidth != null) 'imageWidth': task.imageWidth, + if (task.imageHeight != null) 'imageHeight': task.imageHeight, + }; + } + + /// 删除终端私有目录中的现场图;不存在或删除失败时只保留元数据,不泄露完整路径到日志。 + static Future _deleteLocalFile(String? path) async { + if (path == null || path.isEmpty) return; + try { + final file = File(path); + if (await file.exists()) await file.delete(); + } catch (_) { + logger.w('|订单证据补传|现场临时图片删除失败|'); + } + } + + /// 判断本地任务状态是否已经结束,不允许取消或订单绑定重新激活终态任务。 + static bool _isTerminal(OrderEvidenceTaskStatus? status) => + status == OrderEvidenceTaskStatus.success || + status == OrderEvidenceTaskStatus.permanentFailed || + status == OrderEvidenceTaskStatus.expired || + status == OrderEvidenceTaskStatus.cancelled; + + /// 判断任务是否已经具备任一种服务端订单定位信息。 + static bool _hasOrderReference(OrderEvidenceUploadTaskEntity task) => + (task.androidOrderCode?.isNotEmpty ?? false) || + (task.serverOrderId?.isNotEmpty ?? false); + + /// 判断任务是否来自预约核销的服务端订单预关联,用于在核销提交前保留可恢复重试。 + static bool _isPreparedReservationEvidence( + OrderEvidenceUploadTaskEntity task, + ) => + OrderEvidenceTaskStatus.fromCode(task.status) == + OrderEvidenceTaskStatus.waitingOrder && + (task.serverOrderId?.isNotEmpty ?? false) && + !(task.androidOrderCode?.isNotEmpty ?? false); + + /// 判断服务端拒绝是否属于哈希、格式或归属等确定不可恢复的错误。 + static bool _isPermanentError(Object error) { + if (error is! DioException) return false; + final message = '${error.error ?? ''} ${error.message ?? ''}'; + return const [ + '禁止覆盖', + '哈希不一致', + '不受信任', + '地址不合法', + '不属于当前设备', + '用户不一致', + '样本不一致', + '图片大小与终端上报不一致', + '图片宽度与终端上报不一致', + '图片高度与终端上报不一致', + ].any(message.contains); + } + + /// 将异常映射为不包含 Token、URL、完整路径和图片内容的稳定诊断摘要。 + static String _safeFailureForError(Object error) { + if (error is DioException) { + return switch (error.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout => + '订单证据请求超时', + DioExceptionType.connectionError => '订单证据服务网络不可达', + DioExceptionType.badCertificate => '订单证据服务证书校验失败', + DioExceptionType.cancel => '订单证据请求已取消', + DioExceptionType.badResponse => + _isPermanentError(error) ? '服务端拒绝不可恢复的证据请求' : '订单证据服务暂未接受请求', + DioExceptionType.unknown => '订单证据请求发生未知网络错误', + }; + } + return '订单证据处理异常(${error.runtimeType})'; + } + + /// 把失败说明限制为单行 300 字符;空说明使用调用点给出的安全默认值。 + static String _safeFailure(String? source, {String fallback = '证据处理失败'}) { + var normalized = + (source == null || source.trim().isEmpty ? fallback : source) + .replaceAll('\n', ' ') + .replaceAll('\r', ' ') + .trim(); + if (normalized.contains('/') || normalized.contains('\\')) { + normalized = fallback; + } + return normalized.length <= 300 + ? normalized + : '${normalized.substring(0, 300)}...'; + } +} + +/// 单条任务在本轮批处理中的结果分类,仅用于汇总计数。 +enum _TaskProcessResult { + /// 当前订单尚未绑定或尚未确认支付,本轮没有写入证据。 + skipped, + + /// 服务端已经确认图片可用或永久缺失上报成功。 + succeeded, + + /// 本轮进入退避等待或确定失败。 + failed, +} diff --git a/lib/common/utils/sync/scheduled_task_storage_tool.dart b/lib/common/utils/sync/scheduled_task_storage_tool.dart new file mode 100644 index 0000000..ca1e080 --- /dev/null +++ b/lib/common/utils/sync/scheduled_task_storage_tool.dart @@ -0,0 +1,268 @@ +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 定时任务结构化存储入口,隔离调度器与 Floor DAO 的实体转换细节。 +abstract final class ScheduledTaskStorageTool { + /// 最早版本商品同步任务的默认 Cron,仅用于识别尚未升级的关闭配置。 + static const String _legacyDisabledProductSyncCron = '0 25 0 * * *'; + + /// 上一版本商品同步任务的默认 Cron,仅用于把未自定义配置升级为每 3 小时。 + static const String _legacyFiveMinuteProductSyncCron = '0 */5 * * * *'; + + /// 当前应用数据库中的任务配置 DAO;数据库由 AppControl 在调度器启动前完成初始化。 + static ScheduledTaskDao get _taskDao => + Get.find().dataBase.getScheduledTaskDao; + + /// 当前应用数据库中的任务执行日志 DAO。 + static ScheduledTaskLogDao get _logDao => + Get.find().dataBase.getScheduledTaskLogDao; + + /// 首次升级时把旧 sysJson 导入表中,并补齐新增任务及升级仍沿用旧默认值的商品同步任务。 + /// + /// 商品同步只迁移完整匹配旧默认值的记录;用户调整过 Cron 或空闲策略时保留现场配置。 + static Future initialize({ + required List defaults, + required List legacyConfigs, + required Map legacyRecords, + }) async { + final existing = { + for (final entity in await _taskDao.getAll()) entity.taskCode: entity, + }; + final legacy = {for (final config in legacyConfigs) config.type: config}; + final now = DateTime.now(); + final writes = []; + for (final defaultConfig in defaults) { + final current = existing[defaultConfig.type.name]; + if (current != null) { + if (_usesLegacyProductSyncDefault( + type: defaultConfig.type, + enabled: current.enabled, + cron: current.cronExpression, + idleOnly: current.idleOnly, + )) { + writes.add( + current.copyWith( + enabled: defaultConfig.enabled, + cronExpression: defaultConfig.cron, + idleOnly: defaultConfig.idleOnly, + updateTime: now, + ), + ); + } + continue; + } + final legacyConfig = legacy[defaultConfig.type] ?? defaultConfig; + final config = _usesLegacyProductSyncDefault( + type: legacyConfig.type, + enabled: legacyConfig.enabled, + cron: legacyConfig.cron, + idleOnly: legacyConfig.idleOnly, + ) + ? defaultConfig + : legacyConfig; + final record = legacyRecords[defaultConfig.type]; + writes.add( + ScheduledTaskEntity( + taskCode: config.type.name, + enabled: config.enabled, + cronExpression: config.cron, + idleOnly: config.idleOnly, + lastRunAt: record?.lastRunAt, + lastSuccessAt: record?.lastSuccessAt, + lastStatus: record == null + ? ScheduledTaskRunStatus.notRun.name + : record.success + ? ScheduledTaskRunStatus.success.name + : ScheduledTaskRunStatus.failed.name, + lastMessage: record?.message ?? '尚未执行', + createTime: now, + updateTime: now, + ), + ); + } + if (writes.isNotEmpty) await _taskDao.batchSave(writes); + } + + /// 判断配置是否仍为旧版商品同步默认值,用于升级默认策略且避免覆盖其他任务或自定义规则。 + static bool _usesLegacyProductSyncDefault({ + required ScheduledTaskType type, + required bool enabled, + required String cron, + required bool idleOnly, + }) => + type == ScheduledTaskType.productSync && + idleOnly && + ((!enabled && cron == _legacyDisabledProductSyncCron) || + (enabled && cron == _legacyFiveMinuteProductSyncCron)); + + /// 从 scheduled_task 读取有效任务,并转换为调度器使用的配置和最近执行快照。 + /// + /// 数据库中未知的 taskCode 会被忽略,保证新旧版本任务集合不一致时仍可启动。 + static Future< + ({ + Map configs, + Map records, + })> loadState() async { + final configs = {}; + final records = {}; + for (final entity in await _taskDao.getAll()) { + final type = _taskType(entity.taskCode); + if (type == null) continue; + configs[type] = ScheduledTaskConfig( + type: type, + enabled: entity.enabled, + cron: entity.cronExpression, + idleOnly: entity.idleOnly, + ); + records[type] = ScheduledTaskRecord( + lastRunAt: entity.lastRunAt, + lastSuccessAt: entity.lastSuccessAt, + success: entity.lastStatus == ScheduledTaskRunStatus.success.name || + entity.lastStatus == ScheduledTaskRunStatus.skipped.name, + message: entity.lastMessage, + ); + } + return (configs: configs, records: records); + } + + /// 保存任务开关、Cron 和空闲策略;已有记录会保留历史执行时间和创建时间。 + static Future saveConfig(ScheduledTaskConfig config) async { + final current = await _taskDao.getByCode(config.type.name); + final now = DateTime.now(); + await _taskDao.save( + current == null + ? ScheduledTaskEntity( + taskCode: config.type.name, + enabled: config.enabled, + cronExpression: config.cron, + idleOnly: config.idleOnly, + lastStatus: ScheduledTaskRunStatus.notRun.name, + lastMessage: '尚未执行', + createTime: now, + updateTime: now, + ) + : current.copyWith( + enabled: config.enabled, + cronExpression: config.cron, + idleOnly: config.idleOnly, + updateTime: now, + ), + ); + } + + /// 将一次任务结果写回 scheduled_task 的最近执行快照。 + static Future saveRecord( + ScheduledTaskType type, + ScheduledTaskRecord record, + ScheduledTaskRunStatus status, + ) async { + final current = await _taskDao.getByCode(type.name); + if (current == null) return; + await _taskDao.save( + current.copyWith( + lastRunAt: record.lastRunAt, + lastSuccessAt: record.lastSuccessAt, + lastStatus: status.name, + lastMessage: record.message, + updateTime: DateTime.now(), + ), + ); + } + + /// 插入状态为 running 的开始日志,并返回带自增主键的实体供结束时更新。 + static Future startLog({ + required ScheduledTaskType type, + required ScheduledTaskTriggerType triggerType, + required ScheduledTaskSourceType sourceType, + required String taskName, + required String sourceName, + required DateTime startedAt, + }) async { + final log = ScheduledTaskLogEntity( + taskCode: type.name, + taskName: taskName, + triggerType: triggerType.name, + sourceType: sourceType.name, + sourceName: sourceName, + status: ScheduledTaskRunStatus.running.name, + message: '任务开始执行', + startedAt: startedAt, + ); + final id = await _logDao.save(log); + return ScheduledTaskLogEntity( + id: id, + taskCode: log.taskCode, + taskName: log.taskName, + triggerType: log.triggerType, + sourceType: log.sourceType, + sourceName: log.sourceName, + status: log.status, + message: log.message, + startedAt: log.startedAt, + ); + } + + /// 更新任务日志的最终状态,并将全局历史裁剪到最近 2000 条。 + static Future finishLog(ScheduledTaskLogEntity log) async { + await _logDao.edit(log); + await _logDao.trimHistory(); + } + + /// 查询指定任务最近 100 条日志,可按主要依赖类型进一步筛选。 + static Future> getLogs( + ScheduledTaskType type, { + ScheduledTaskSourceType? sourceType, + int limit = 100, + }) => + _logDao.getByTask(type.name, sourceType?.name ?? '', limit); + + /// 分页查询指定任务日志,同时返回总数和校正后的当前页。 + /// + /// 页容量限制在 1 到 100 条;当历史日志裁剪导致请求页超出范围时,自动回退到最后一页, + /// 避免分页组件仍显示有效总数但列表为空。 + static Future< + ({ + List records, + int total, + int currentPage, + })> getLogPage( + ScheduledTaskType type, { + ScheduledTaskSourceType? sourceType, + required int currentPage, + required int pageSize, + }) async { + final sourceCode = sourceType?.name ?? ''; + final normalizedPageSize = pageSize.clamp(1, 100).toInt(); + final total = await _logDao.countByTask(type.name, sourceCode) ?? 0; + final totalPages = total == 0 ? 1 : (total / normalizedPageSize).ceil(); + final normalizedCurrentPage = currentPage.clamp(1, totalPages).toInt(); + final records = total == 0 + ? const [] + : await _logDao.getPageByTask( + type.name, + sourceCode, + normalizedPageSize, + (normalizedCurrentPage - 1) * normalizedPageSize, + ); + return ( + records: records, + total: total, + currentPage: normalizedCurrentPage, + ); + } + + /// 查询指定任务最近一条日志;尚未执行时返回空。 + static Future getLatestLog( + ScheduledTaskType type, + ) => + _logDao.getLatest(type.name); + + /// 将数据库中的稳定任务编码转换为当前版本枚举,未知编码返回空。 + static ScheduledTaskType? _taskType(String code) { + for (final type in ScheduledTaskType.values) { + if (type.name == code) return type; + } + return null; + } +} diff --git a/lib/common/utils/sync/sync_check_tool.dart b/lib/common/utils/sync/sync_check_tool.dart index 87710e7..d087e78 100644 --- a/lib/common/utils/sync/sync_check_tool.dart +++ b/lib/common/utils/sync/sync_check_tool.dart @@ -1,184 +1,1175 @@ import 'dart:async'; -import 'dart:io'; +import 'package:cron/cron.dart'; import 'package:get/get.dart'; import 'package:mqtt_client/mqtt_client.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +/// 应用进程内任务调度入口。 +/// +/// 收银终端常驻时 Cron 可提供秒级规则;Android 杀死进程后不会继续执行,启动后由本类重新注册。 class SyncCheckTool { - static const _tag = "自动检查任务"; + /// 调度器写入本地诊断日志时使用的稳定模块标签。 + static const _tag = '终端任务调度'; - SyncCheckTool._privateConstructor(); - static final SyncCheckTool _instance = SyncCheckTool._privateConstructor(); - static SyncCheckTool get instance => _instance; + /// 禁止外部创建第二套调度状态,确保 Cron、运行集合和待执行队列全局唯一。 + SyncCheckTool._(); - bool _isRunning = false; + /// 应用级任务调度单例,由 AppControl 初始化并由设置页读取响应式状态。 + static final SyncCheckTool instance = SyncCheckTool._(); -// ************************************************************************** -// 定时器配置 start -// ************************************************************************** + /// 当前已加载的任务配置,以稳定任务类型为键并向设置页提供响应式更新。 + final configs = {}.obs; - Duration mqInterval = const Duration(seconds: 60); + /// 每个任务最近一次执行结果快照,以稳定任务类型为键。 + final records = {}.obs; - Duration printInterval = const Duration(seconds: 60 * 10); + /// 正在实际执行业务动作的任务集合,用于禁止同任务并发并驱动运行状态 UI。 + final runningTasks = {}.obs; - Duration broadcastInterval = const Duration(seconds: 60 * 2); + /// 正在等待安全窗口或等待前一次同类任务结束的任务集合。 + final pendingTasks = {}.obs; - Timer? mqTimer; + /// 最近一次终端日志上传统计,供设置页展示扫描、跳过、成功和失败数量。 + final terminalLogSummary = const TerminalLogUploadSummary().obs; - Timer? printTimer; + /// 当前进程内 Cron 调度器;任务全部停止后置空并关闭其内部计时资源。 + Cron? _cron; - Timer? broadcastTimer; + /// 已注册到 Cron 的任务句柄,以任务类型为键支持单任务重建和取消。 + final Map _scheduledTasks = {}; -// ************************************************************************** -// 定时器配置 end -// ************************************************************************** + /// 同任务合并后的待执行请求,记录触发来源和需要满足的空闲级别。 + final Map _pendingRequests = {}; - startAll() { - if (_isRunning) return; - checkMq(); - checkPrint(); - checkBroadcast(); - checkExpiredLog(); + /// 每 5 秒检查待执行任务安全窗口的轮询计时器;队列清空后自动释放。 + Timer? _idleTimer; + + /// 正在进行的释放操作,避免生命周期重复回调并发关闭同一个 Cron 实例。 + Future? _disposeFuture; + + /// 是否已经完成本轮启动,防止 AppControl 重复注册相同 Cron 任务。 + bool _started = false; + + /// 当前注册到 Cron 的任务类型快照。 + Set get scheduledTaskTypes => + Set.unmodifiable(_scheduledTasks.keys); + + /// 当前进程最近一次补排部门人脸同步的本地日期,保证同一天最多自动补排一次。 + DateTime? _lastDepartmentFaceCatchUpDate; + + /// 常用规则均使用 cron 0.6.2 可解析的标准字段、范围和步长,不包含 Quartz 专属的 L/W/#。 + static const List cronPresets = [ + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 30 秒', + expression: '*/30 * * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 1 分钟', + expression: '0 * * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 2 分钟', + expression: '0 */2 * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 3 分钟', + expression: '0 */3 * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 5 分钟', + expression: '0 */5 * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 10 分钟', + expression: '0 */10 * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 15 分钟', + expression: '0 */15 * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 20 分钟', + expression: '0 */20 * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 30 分钟', + expression: '0 */30 * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 45 分钟', + expression: '0 */45 * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每小时', + expression: '0 0 * * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 2 小时', + expression: '0 0 */2 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 3 小时', + expression: '0 0 */3 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 4 小时', + expression: '0 0 */4 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 6 小时', + expression: '0 0 */6 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 8 小时', + expression: '0 0 */8 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.interval, + label: '每 12 小时', + expression: '0 0 */12 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 00:00', + expression: '0 0 0 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 00:10', + expression: '0 10 0 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 00:30', + expression: '0 30 0 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 00:40', + expression: '0 40 0 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 01:00', + expression: '0 0 1 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 02:00', + expression: '0 0 2 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 03:00', + expression: '0 0 3 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 04:00', + expression: '0 0 4 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 06:00', + expression: '0 0 6 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 08:00', + expression: '0 0 8 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 09:00', + expression: '0 0 9 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 12:00', + expression: '0 0 12 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 18:00', + expression: '0 0 18 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 21:00', + expression: '0 0 21 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 22:00', + expression: '0 0 22 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.daily, + label: '每天 23:00', + expression: '0 0 23 * * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.weekly, + label: '工作日 08:00', + expression: '0 0 8 * * 1-5'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.weekly, + label: '工作日 18:00', + expression: '0 0 18 * * 1-5'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.weekly, + label: '每周一 00:00', + expression: '0 0 0 * * 1'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.weekly, + label: '每周一 08:00', + expression: '0 0 8 * * 1'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.weekly, + label: '每周三 00:00', + expression: '0 0 0 * * 3'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.weekly, + label: '每周五 18:00', + expression: '0 0 18 * * 5'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.weekly, + label: '每周六 00:00', + expression: '0 0 0 * * 6'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.weekly, + label: '每周日 00:00', + expression: '0 0 0 * * 7'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.monthly, + label: '每月 1 日 00:00', + expression: '0 0 0 1 * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.monthly, + label: '每月 1 日 03:00', + expression: '0 0 3 1 * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.monthly, + label: '每月 5 日 00:00', + expression: '0 0 0 5 * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.monthly, + label: '每月 10 日 00:00', + expression: '0 0 0 10 * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.monthly, + label: '每月 15 日 00:00', + expression: '0 0 0 15 * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.monthly, + label: '每月 20 日 00:00', + expression: '0 0 0 20 * *'), + ScheduledCronPreset( + group: ScheduledCronPresetGroup.monthly, + label: '每月 25 日 00:00', + expression: '0 0 0 25 * *'), + ]; + + /// 首次建表或后续新增任务时写入的默认配置,不覆盖设备已经调整过的规则。 + static const List defaultConfigs = [ + ScheduledTaskConfig( + type: ScheduledTaskType.mqReconnect, + enabled: true, + cron: '0 * * * * *', + idleOnly: false, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.printCheck, + enabled: true, + cron: '0 */10 * * * *', + idleOnly: false, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.broadcastCheck, + enabled: true, + cron: '0 */2 * * * *', + idleOnly: false, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.deviceStatus, + enabled: true, + cron: '0 * * * * *', + idleOnly: false, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.autoUpdate, + enabled: true, + cron: '0 30 0 * * *', + idleOnly: true, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.configSync, + enabled: false, + cron: '0 5 0 * * *', + idleOnly: true, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.departmentFaceSync, + enabled: true, + cron: '0 40 0 * * *', + idleOnly: true, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.classifySync, + enabled: false, + cron: '0 15 0 * * *', + idleOnly: true, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.productSync, + enabled: true, + cron: '0 0 */3 * * *', + idleOnly: true, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.offlineOrderSync, + enabled: false, + cron: '0 35 0 * * *', + idleOnly: true, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.orderEvidenceUpload, + enabled: true, + cron: '0 */5 * * * *', + idleOnly: true, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.terminalLogUpload, + enabled: true, + cron: '0 0 0 * * *', + idleOnly: true, + ), + ScheduledTaskConfig( + type: ScheduledTaskType.expiredLogCleanup, + enabled: true, + cron: '0 10 0 * * *', + idleOnly: true, + ), + ]; + + /// 初始化结构化配置并注册全部已启用任务;同一生命周期内重复调用直接返回。 + Future startAll() async { + await _disposeFuture; + if (_started) return; + _started = true; + _cron ??= Cron(); + await _loadState(); + for (final config in configs.values) { + await _rebuildTask(config); + } + queueMissedDepartmentFaceSync(); } - void dispose() { - _isRunning = false; - mqTimer?.cancel(); - mqTimer = null; + /// 在每天 00:40 之后检查当天是否成功完成部门人脸同步,并在首次启动或恢复时补排一次。 + /// + /// 补排只加入同一人脸任务队列并要求完整空闲,不会阻塞启动或打断支付、识别和业务弹框。 + void queueMissedDepartmentFaceSync() { + if (!_started) return; + final config = configs[ScheduledTaskType.departmentFaceSync]; + if (config == null || !config.enabled) return; + final now = DateTime.now(); + final scheduledAt = DateTime(now.year, now.month, now.day, 0, 40); + if (now.isBefore(scheduledAt)) return; + final lastSuccess = + records[ScheduledTaskType.departmentFaceSync]?.lastSuccessAt; + if (_isSameLocalDate(lastSuccess, now) || + _isSameLocalDate(_lastDepartmentFaceCatchUpDate, now)) { + return; + } + _lastDepartmentFaceCatchUpDate = now; + unawaited(trigger( + ScheduledTaskType.departmentFaceSync, + catchUp: true, + triggerType: ScheduledTaskTriggerType.system, + )); } - // 检查mq是否可以连接,是否需要重新连接 - checkMq() { - String logMsg = "检查mq是否可以连接,是否需要重新连接"; - final inSeconds = mqInterval.inSeconds; - if (mqTimer != null && mqTimer!.isActive) { - mqTimer?.cancel(); - mqTimer = null; + /// 判断两个可空时间是否属于终端本地时区的同一自然日。 + static bool _isSameLocalDate(DateTime? left, DateTime right) => + left != null && + left.year == right.year && + left.month == right.month && + left.day == right.day; + + /// 完成旧 sysJson 一次性迁移,再从 SQLite 加载任务配置、最近结果和上传统计。 + Future _loadState() async { + final legacyConfigs = (await AppSettingTool.getScheduledTaskConfigs()) + .where((item) => isValidCron(item.cron)) + .toList(growable: false); + final legacyRecords = await AppSettingTool.getScheduledTaskRecords(); + final legacyUploadStates = + await AppSettingTool.getTerminalLogUploadStates(); + await ScheduledTaskStorageTool.initialize( + defaults: defaultConfigs, + legacyConfigs: legacyConfigs, + legacyRecords: legacyRecords, + ); + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + final canImportUploadState = hardId != null && hardId.isNotEmpty; + if (canImportUploadState && legacyUploadStates.isNotEmpty) { + await FileUploadRecordTool.importLegacy(hardId, legacyUploadStates); } - if (mqTimer != null) { - mqTimer?.cancel(); - mqTimer = null; + await AppSettingTool.clearLegacyScheduledTaskState( + includeUploadState: canImportUploadState, + ); + final state = await ScheduledTaskStorageTool.loadState(); + configs.assignAll(state.configs); + records.assignAll(state.records); + final latestUploadLog = await ScheduledTaskStorageTool.getLatestLog( + ScheduledTaskType.terminalLogUpload, + ); + if (latestUploadLog != null) { + terminalLogSummary.value = TerminalLogUploadSummary( + scanned: latestUploadLog.scannedCount ?? 0, + skipped: latestUploadLog.skippedCount ?? 0, + succeeded: latestUploadLog.successCount ?? 0, + failed: latestUploadLog.failureCount ?? 0, + lastSuccessAt: + latestUploadLog.status == ScheduledTaskRunStatus.success.name + ? latestUploadLog.finishedAt + : records[ScheduledTaskType.terminalLogUpload]?.lastSuccessAt, + ); } - mqTimer = Timer.periodic(mqInterval, (Timer timer) async { - final isNetwork = AppNetworkTool.isAutoNetwork(); - if (!isNetwork) return; - logger.i("|$_tag|$logMsg,$inSeconds秒一次,开始执行"); - if (MqManager.state == MqttConnectionState.connected) return; + } + + /// 校验 cron 0.6.2 使用的六段表达式及每段数值范围,返回是否可以安全注册。 + static bool isValidCron(String expression) { + final fields = expression.trim().split(RegExp(r'\s+')); + if (fields.length != 6) return false; + final ranges = const [ + (min: 0, max: 59), + (min: 0, max: 59), + (min: 0, max: 23), + (min: 1, max: 31), + (min: 1, max: 12), + (min: 0, max: 7), + ]; + for (var index = 0; index < fields.length; index++) { + if (!_isValidCronField( + fields[index], + min: ranges[index].min, + max: ranges[index].max, + )) { + return false; + } + } + try { + Schedule.parse(expression); + return true; + } catch (_) { + return false; + } + } + + /// cron 0.6.2 会静默过滤越界数字,因此保存前额外校验每一段,避免 `99 * * * * *` + /// 这类永远不会触发的表达式被误判为有效。 + static bool _isValidCronField( + String field, { + required int min, + required int max, + }) { + if (field.isEmpty) return false; + for (final segment in field.split(',')) { + final intervalParts = segment.split('/'); + if (intervalParts.length > 2) return false; + final base = intervalParts.first; + if (intervalParts.length == 2) { + final interval = int.tryParse(intervalParts.last); + if (interval == null || interval <= 0) return false; + if (base != '*' && !base.contains('-')) return false; + } + if (base == '*') continue; + if (base.contains('-')) { + final range = base.split('-'); + if (range.length != 2) return false; + final lower = int.tryParse(range.first); + final upper = int.tryParse(range.last); + if (lower == null || + upper == null || + lower < min || + upper > max || + lower > upper) { + return false; + } + continue; + } + final value = int.tryParse(base); + if (value == null || value < min || value > max) return false; + } + return true; + } + + /// 保存单个任务配置并仅重建该任务;任一步失败时恢复旧配置和旧调度句柄。 + Future updateConfig(ScheduledTaskConfig config) async { + if (!isValidCron(config.cron)) { + throw const FormatException('请输入有效的 6 段 Cron 表达式'); + } + final previous = configs[config.type]; + configs[config.type] = config; + try { + await _rebuildTask(config); + await ScheduledTaskStorageTool.saveConfig(config); + } catch (error, stack) { + if (previous == null) { + configs.remove(config.type); + } else { + configs[config.type] = previous; + } + // 调度注册和本地配置必须一起回滚,避免界面显示旧值但后台仍按新规则运行。 try { - AppMqTool.mqRefresh(); - } catch (e) { - logger.i("|$_tag|$logMsg,$inSeconds秒一次,执行失败,error:$e"); - } finally { - logger.i("|$_tag|$logMsg,$inSeconds秒一次,执行完成"); + if (previous == null) { + await _scheduledTasks.remove(config.type)?.cancel(); + } else { + await _rebuildTask(previous); + } + if (previous != null) { + await ScheduledTaskStorageTool.saveConfig(previous); + } + } catch (rollbackError, rollbackStack) { + logger.e( + '|$_tag|任务配置回滚失败type:${config.type.name}|error:$rollbackError|stack:$rollbackStack|', + ); + } + logger.e( + '|$_tag|任务配置保存失败type:${config.type.name}|error:$error|stack:$stack|', + ); + rethrow; + } + } + + /// 取消任务旧句柄,并按当前开关和 Cron 表达式重新注册对应任务。 + Future _rebuildTask(ScheduledTaskConfig config) async { + await _scheduledTasks.remove(config.type)?.cancel(); + if (!_started || !config.enabled) return; + _scheduledTasks[config.type] = _cron!.schedule( + Schedule.parse(config.cron), + () => trigger(config.type, scheduled: true), + ); + } + + /// 手动和 MQTT 触发不检查时间规则;同任务重复触发只保留一个合并后的待执行请求。 + Future trigger( + ScheduledTaskType type, { + bool currentLogOnly = false, + bool scheduled = false, + bool catchUp = false, + Set? faceUserIds, + bool debugAllFaces = false, + ScheduledTaskTriggerType triggerType = ScheduledTaskTriggerType.manual, + }) async { + final config = + configs[type] ?? defaultConfigs.firstWhere((item) => item.type == type); + final request = _PendingTaskRequest( + currentLogOnly: currentLogOnly, + requireSafeWindow: !scheduled || config.idleOnly, + requireFullIdle: (scheduled || catchUp) && config.idleOnly, + triggerType: scheduled ? ScheduledTaskTriggerType.cron : triggerType, + faceUserIds: faceUserIds == null ? null : Set.unmodifiable(faceUserIds), + fullFaceSnapshot: + type == ScheduledTaskType.departmentFaceSync && faceUserIds == null, + debugAllFaces: debugAllFaces, + ); + if (runningTasks.contains(type)) { + _enqueue(type, request); + return; + } + if (!_isReady(request)) { + _enqueue(type, request); + _ensureIdleTimer(); + return; + } + await _execute(type, request: request); + } + + /// 将任务加入等待集合;同任务已有请求时按更完整的处理范围合并。 + void _enqueue(ScheduledTaskType type, _PendingTaskRequest request) { + final existing = _pendingRequests[type]; + _pendingRequests[type] = existing?.merge(request) ?? request; + pendingTasks.add(type); + } + + /// 判断请求当前是否满足无需等待、业务安全窗口或完整空闲窗口中的对应条件。 + bool _isReady(_PendingTaskRequest request) { + if (!request.requireSafeWindow) return true; + return request.requireFullIdle + ? SyncIdleTool.instance.isIdle + : SyncIdleTool.instance.isSafeWindow; + } + + /// 从可观察集合和内部请求映射同时取出一个待执行任务,避免 UI 与队列状态分离。 + _PendingTaskRequest? _takePending(ScheduledTaskType type) { + pendingTasks.remove(type); + return _pendingRequests.remove(type); + } + + /// 在存在待执行任务时启动唯一轮询器,安全窗口满足后按队列快照依次执行。 + void _ensureIdleTimer() { + _idleTimer ??= Timer.periodic(const Duration(seconds: 5), (_) async { + if (pendingTasks.isEmpty) { + _idleTimer?.cancel(); + _idleTimer = null; + return; + } + final tasks = pendingTasks.toList(growable: false); + for (final type in tasks) { + if (runningTasks.contains(type)) continue; + final request = _pendingRequests[type]; + if (request == null || !_isReady(request)) continue; + _takePending(type); + await _execute(type, request: request); } }); } - // 定时检查需不要打印 - checkPrint() { - String logMsg = "定时检查需不要打印"; - final inSeconds = printInterval.inSeconds; - if (printTimer != null && printTimer!.isActive) { - printTimer?.cancel(); - printTimer = null; + /// 执行一次任务并同步运行集合、最近结果及结构化日志,结束后处理合并请求。 + Future _execute( + ScheduledTaskType type, { + required _PendingTaskRequest request, + }) async { + runningTasks.add(type); + final startedAt = DateTime.now(); + final descriptor = _taskDescriptor(type); + ScheduledTaskLogEntity? executionLog; + try { + executionLog = await ScheduledTaskStorageTool.startLog( + type: type, + triggerType: request.triggerType, + sourceType: descriptor.sourceType, + taskName: descriptor.title, + sourceName: descriptor.sourceName, + startedAt: startedAt, + ); + } catch (error, stack) { + // 诊断日志写入失败不能阻断收银终端本身的同步任务。 + logger.w('|$_tag|创建任务日志失败type:${type.name}|error:$error|stack:$stack|'); } - if (printTimer != null) { - printTimer?.cancel(); - printTimer = null; + final heavy = _isHeavy(type); + if (heavy) SyncIdleTool.instance.beginHeavyTask(); + try { + final result = await _runAction( + type, + request: request, + ); + records[type] = ScheduledTaskRecord( + lastRunAt: startedAt, + lastSuccessAt: result.status == ScheduledTaskRunStatus.success + ? DateTime.now() + : records[type]?.lastSuccessAt, + success: true, + message: result.message, + ); + await _safeSaveRecord( + type, + records[type]!, + result.status, + ); + if (executionLog != null) { + await _safeFinishLog( + executionLog.finish( + nextStatus: result.status.name, + nextMessage: result.message, + at: DateTime.now(), + scanned: result.summary?.scanned, + skipped: result.summary?.skipped, + succeeded: result.summary?.succeeded, + failed: result.summary?.failed, + ), + ); + } + } catch (error, stack) { + if (error is TerminalLogUploadException) { + terminalLogSummary.value = error.summary; + } + final message = switch (error) { + TerminalLogUploadException() => error.summary.label, + FaceSyncPartialFailureException() => error.summary.message, + _ => error.toString(), + }; + records[type] = ScheduledTaskRecord( + lastRunAt: startedAt, + lastSuccessAt: records[type]?.lastSuccessAt, + success: false, + message: message, + ); + await _safeSaveRecord( + type, + records[type]!, + ScheduledTaskRunStatus.failed, + ); + if (executionLog != null) { + await _safeFinishLog( + executionLog.finish( + nextStatus: ScheduledTaskRunStatus.failed.name, + nextMessage: message, + at: DateTime.now(), + nextErrorType: error.runtimeType.toString(), + nextErrorMessage: _safeError(error), + nextErrorStack: _safeStack(stack), + scanned: switch (error) { + TerminalLogUploadException() => error.summary.scanned, + FaceSyncPartialFailureException() => error.summary.scanned, + _ => null, + }, + skipped: switch (error) { + TerminalLogUploadException() => error.summary.skipped, + FaceSyncPartialFailureException() => error.summary.skipped, + _ => null, + }, + succeeded: switch (error) { + TerminalLogUploadException() => error.summary.succeeded, + FaceSyncPartialFailureException() => error.summary.changed, + _ => null, + }, + failed: switch (error) { + TerminalLogUploadException() => error.summary.failed, + FaceSyncPartialFailureException() => error.summary.failed, + _ => null, + }, + ), + ); + } + if (error is ScheduledTaskDependencyException) { + logger.w('|$_tag|任务依赖暂不可用type:${type.name}|error:${error.message}|'); + } else { + logger.e( + '|$_tag|任务执行失败type:${type.name}|error:$error|stack:$stack|', + ); + } + } finally { + if (heavy) SyncIdleTool.instance.endHeavyTask(); + runningTasks.remove(type); + final nextRequest = _pendingRequests[type]; + if (nextRequest != null) { + if (_isReady(nextRequest)) { + _takePending(type); + unawaited(_execute(type, request: nextRequest)); + } else { + _ensureIdleTimer(); + } + } } - printTimer = Timer.periodic(printInterval, (Timer timer) async { - final isNetwork = AppNetworkTool.isAutoNetwork(); - if (!isNetwork) return; - logger.i("|$_tag|$logMsg,$inSeconds秒一次,开始执行"); - try { + } + + /// 结构化诊断数据属于旁路能力,SQLite 暂时不可写时不能把已经成功的业务任务改判为失败。 + Future _safeSaveRecord( + ScheduledTaskType type, + ScheduledTaskRecord record, + ScheduledTaskRunStatus status, + ) async { + try { + await ScheduledTaskStorageTool.saveRecord(type, record, status); + } catch (error, stack) { + logger.w( + '|$_tag|保存任务状态失败type:${type.name}|error:$error|stack:$stack|', + ); + } + } + + /// 完成日志和历史裁剪失败只写本地告警,原始 HTTP、MQTT 或插件结果保持不变。 + Future _safeFinishLog(ScheduledTaskLogEntity log) async { + try { + await ScheduledTaskStorageTool.finishLog(log); + } catch (error, stack) { + logger.w( + '|$_tag|完成任务日志失败type:${log.taskCode}|error:$error|stack:$stack|', + ); + } + } + + /// 将稳定任务类型分派到既有 MQTT、HTTP、插件或文件业务入口并返回统一结果。 + Future<_ScheduledTaskActionResult> _runAction( + ScheduledTaskType type, { + required _PendingTaskRequest request, + }) async { + switch (type) { + case ScheduledTaskType.mqReconnect: + if (!AppNetworkTool.isAutoNetwork()) { + return const _ScheduledTaskActionResult.skipped('网络不可用,本次跳过'); + } + if (MqManager.state == MqttConnectionState.connected) { + return const _ScheduledTaskActionResult.success('消息服务连接正常'); + } + await AppMqTool.mqRefresh(); + if (MqManager.state != MqttConnectionState.connected) { + throw ScheduledTaskDependencyException( + MqManager.connectionFailureReason ?? '消息服务暂未连接', + ); + } + return const _ScheduledTaskActionResult.success('消息服务重连成功'); + case ScheduledTaskType.printCheck: + if (!AppNetworkTool.isAutoNetwork()) { + return const _ScheduledTaskActionResult.skipped('网络不可用,本次跳过'); + } + await SyncPrintTool.pullPendingTasks(); await SyncPrintTool.doCheckPrint(); await SyncPrintTool.doCheckSync(); - } catch (e) { - logger.i("|$_tag|$logMsg,$inSeconds秒一次,执行失败,error:$e"); - } finally { - logger.i("|$_tag|$logMsg,$inSeconds秒一次,执行完成"); - } - }); - } - - // 定时检查需不要广播 - checkBroadcast() { - String logMsg = "定时检查需不要广播"; - final inSeconds = broadcastInterval.inSeconds; - if (broadcastTimer != null && broadcastTimer!.isActive) { - broadcastTimer?.cancel(); - broadcastTimer = null; - } - if (broadcastTimer != null) { - broadcastTimer?.cancel(); - broadcastTimer = null; - } - broadcastTimer = Timer.periodic(broadcastInterval, (Timer timer) async { - final isNetwork = AppNetworkTool.isAutoNetwork(); - if (!isNetwork) return; - logger.i("|$_tag|$logMsg,$inSeconds秒一次,开始执行"); - try { + return const _ScheduledTaskActionResult.success('打印任务检查完成'); + case ScheduledTaskType.broadcastCheck: + if (!AppNetworkTool.isAutoNetwork()) { + return const _ScheduledTaskActionResult.skipped('网络不可用,本次跳过'); + } final setting = await AppSettingTool.getSysSetting(); + final configuredCurrentHourType = + await AppSettingTool.getCurrentProductHourType(); await SyncTakeFoodBroadcastTool.doCheckBroadcast( - setting.hourType ?? ProductHourType.TOTAL, + configuredCurrentHourType ?? + setting.hourType ?? + ProductHourType.TOTAL, ); - } catch (e) { - logger.i("|$_tag|$logMsg,$inSeconds秒一次,执行失败,error:$e"); - } finally { - logger.i("|$_tag|$logMsg,$inSeconds秒一次,执行完成"); - } - }); + return const _ScheduledTaskActionResult.success('取餐广播检查完成'); + case ScheduledTaskType.deviceStatus: + await AppDeviceStatusTool.refreshAll(); + return const _ScheduledTaskActionResult.success('设备状态检查完成'); + case ScheduledTaskType.autoUpdate: + final update = await TerminalAutoUpdateTool.run(); + return update.skipped + ? _ScheduledTaskActionResult.skipped(update.message) + : _ScheduledTaskActionResult.success(update.message); + case ScheduledTaskType.configSync: + await SyncConfigTool.autoSyncSysInfo(); + return const _ScheduledTaskActionResult.success('设备配置同步完成'); + case ScheduledTaskType.departmentFaceSync: + final mode = request.debugAllFaces + ? FaceSyncMode.debugAllSnapshot + : request.faceUserIds == null + ? FaceSyncMode.departmentSnapshot + : FaceSyncMode.userDelta; + final summary = await SyncFaceTool.sync( + mode: mode, + userIds: request.faceUserIds, + ); + if (summary.changed > 0 && + request.triggerType == ScheduledTaskTriggerType.mqtt && + Get.isRegistered()) { + Get.find().mqUpdateEventBus.fire(MqtActionEnum.FACE); + } + if (summary.hasFailures) { + throw FaceSyncPartialFailureException(summary); + } + return _ScheduledTaskActionResult.success( + summary.message, + summary: TerminalLogUploadSummary( + scanned: summary.scanned, + skipped: summary.skipped, + succeeded: summary.changed, + failed: summary.failed, + ), + ); + case ScheduledTaskType.classifySync: + await SyncProductTool.syncClassify(); + return const _ScheduledTaskActionResult.success('分类同步完成'); + case ScheduledTaskType.productSync: + await SyncProductTool.syncProduct(); + if (Get.isRegistered()) { + final setting = await AppSettingTool.getSysSetting(); + // 定时任务完成后通知仍存活的收银页重读本地快照,库存才能在不切页的情况下刷新。 + Get.find().mqUpdateEventBus.fire( + setting.sysType == SysType.CANTEEN + ? MqtActionEnum.CANTEEN_PRODUCT + : MqtActionEnum.SUPERMARKET_PRODUCT, + ); + } + return const _ScheduledTaskActionResult.success('商品同步完成'); + case ScheduledTaskType.offlineOrderSync: + await SyncOrderTool.syncOfflineOrder(); + return const _ScheduledTaskActionResult.success('离线订单同步完成'); + case ScheduledTaskType.orderEvidenceUpload: + if (!AppNetworkTool.isAutoNetwork()) { + return const _ScheduledTaskActionResult.skipped('网络不可用,本次跳过'); + } + final summary = await OrderEvidenceUploadTool.processPending(); + return _ScheduledTaskActionResult.success( + summary.message, + summary: TerminalLogUploadSummary( + scanned: summary.scanned, + skipped: summary.skipped, + succeeded: summary.succeeded, + failed: summary.failed, + ), + ); + case ScheduledTaskType.terminalLogUpload: + final summary = await TerminalLogUploadTool.upload( + currentOnly: request.currentLogOnly, + ); + terminalLogSummary.value = summary; + return _ScheduledTaskActionResult.success( + summary.label, + summary: summary, + ); + case ScheduledTaskType.expiredLogCleanup: + final uploadSummary = await TerminalLogUploadTool.upload(); + terminalLogSummary.value = uploadSummary; + final deleted = await TerminalLogUploadTool.cleanupExpiredLogs(); + return _ScheduledTaskActionResult.success( + '上传检查完成,清理 $deleted 个过期日志', + summary: uploadSummary, + ); + } } - // 检查过期日志(超过3个月的日志文件都要删除) - checkExpiredLog() async { - const logMsg = "检查过期日志"; - try { - final backupPath = await AppFileTool.getLoggerBackupPath(); - if (backupPath == null || backupPath.isEmpty) { - logger.w("|$_tag|$logMsg,日志备份路径为空"); - return; - } - final loggerBackupDir = Directory(backupPath).parent.parent; - if (!await loggerBackupDir.exists()) { - logger.i("|$_tag|$logMsg,日志目录不存在 path:${loggerBackupDir.path}"); - return; - } - final expireBefore = DateTime( - DateTime.now().year, - DateTime.now().month - 3, - DateTime.now().day, + /// 返回任务是否会占用较多网络、磁盘或数据处理资源,需要进入重型任务互斥区。 + bool _isHeavy(ScheduledTaskType type) { + return const { + ScheduledTaskType.configSync, + ScheduledTaskType.departmentFaceSync, + ScheduledTaskType.classifySync, + ScheduledTaskType.productSync, + ScheduledTaskType.offlineOrderSync, + ScheduledTaskType.orderEvidenceUpload, + ScheduledTaskType.autoUpdate, + ScheduledTaskType.terminalLogUpload, + ScheduledTaskType.expiredLogCleanup, + }.contains(type); + } + + /// 返回任务设置页和执行日志共用的中文名称。 + static String taskTitle(ScheduledTaskType type) => + _taskDescriptor(type).title; + + /// 返回任务执行日志使用的主要依赖类型。 + static ScheduledTaskSourceType taskSourceType(ScheduledTaskType type) => + _taskDescriptor(type).sourceType; + + /// 返回任务执行日志使用的具体依赖名称。 + static String taskSourceName(ScheduledTaskType type) => + _taskDescriptor(type).sourceName; + + /// 从 SQLite 查询指定任务最近日志,并可按依赖类型过滤。 + Future> loadLogs( + ScheduledTaskType type, { + ScheduledTaskSourceType? sourceType, + }) => + ScheduledTaskStorageTool.getLogs(type, sourceType: sourceType); + + /// 从 SQLite 分页查询指定任务日志,并返回筛选后的总数与校正页码。 + Future< + ({ + List records, + int total, + int currentPage, + })> loadLogPage( + ScheduledTaskType type, { + ScheduledTaskSourceType? sourceType, + required int currentPage, + required int pageSize, + }) => + ScheduledTaskStorageTool.getLogPage( + type, + sourceType: sourceType, + currentPage: currentPage, + pageSize: pageSize, ); - final entities = loggerBackupDir.listSync(recursive: true); - int deleteCount = 0; - for (final entity in entities) { - if (entity is! File) continue; - if (!entity.path.endsWith(".log")) continue; - final stat = await entity.stat(); - if (stat.modified.isBefore(expireBefore)) { - await entity.delete(); - deleteCount++; - logger.i("|$_tag|$logMsg,删除过期日志 path:${entity.path}"); - } - } - final monthDirs = loggerBackupDir - .listSync() - .whereType() - .toList() - ..sort((a, b) => b.path.compareTo(a.path)); - for (final dir in monthDirs) { - if (!await dir.exists()) continue; - final isEmpty = dir.listSync().isEmpty; - if (isEmpty) { - await dir.delete(); - logger.i("|$_tag|$logMsg,删除空目录 path:${dir.path}"); - } - } - logger.i("|$_tag|$logMsg,执行完成,删除日志数量:$deleteCount"); - } catch (e, stack) { - logger.e("|$_tag|$logMsg,执行失败,error:$e,stack:$stack"); + + /// 将任务类型映射为稳定展示名称和依赖分类,不把 UI 文案写入持久化主键。 + static _ScheduledTaskDescriptor _taskDescriptor(ScheduledTaskType type) => + switch (type) { + ScheduledTaskType.mqReconnect => const _ScheduledTaskDescriptor( + title: 'MQTT 重连', + sourceType: ScheduledTaskSourceType.mqtt, + sourceName: 'MQTT 消息服务', + ), + ScheduledTaskType.printCheck => const _ScheduledTaskDescriptor( + title: '打印任务检查', + sourceType: ScheduledTaskSourceType.http, + sourceName: 'HTTP 接口与打印插件', + ), + ScheduledTaskType.broadcastCheck => const _ScheduledTaskDescriptor( + title: '取餐广播检查', + sourceType: ScheduledTaskSourceType.local, + sourceName: '本地广播队列', + ), + ScheduledTaskType.deviceStatus => const _ScheduledTaskDescriptor( + title: '设备状态检查', + sourceType: ScheduledTaskSourceType.plugin, + sourceName: '终端硬件插件', + ), + ScheduledTaskType.autoUpdate => const _ScheduledTaskDescriptor( + title: 'APK 自动更新', + sourceType: ScheduledTaskSourceType.http, + sourceName: '版本接口、私有下载目录与 Root 安装服务', + ), + ScheduledTaskType.configSync => const _ScheduledTaskDescriptor( + title: '设备配置同步', + sourceType: ScheduledTaskSourceType.http, + sourceName: 'HTTP 配置接口', + ), + ScheduledTaskType.departmentFaceSync => const _ScheduledTaskDescriptor( + title: '本部门人脸同步', + sourceType: ScheduledTaskSourceType.http, + sourceName: 'HTTP 人脸快照与虹软插件', + ), + ScheduledTaskType.classifySync => const _ScheduledTaskDescriptor( + title: '分类同步', + sourceType: ScheduledTaskSourceType.http, + sourceName: 'HTTP 分类接口', + ), + ScheduledTaskType.productSync => const _ScheduledTaskDescriptor( + title: '商品同步', + sourceType: ScheduledTaskSourceType.http, + sourceName: 'HTTP 商品接口', + ), + ScheduledTaskType.offlineOrderSync => const _ScheduledTaskDescriptor( + title: '离线订单同步', + sourceType: ScheduledTaskSourceType.http, + sourceName: 'HTTP 订单接口', + ), + ScheduledTaskType.orderEvidenceUpload => const _ScheduledTaskDescriptor( + title: '订单证据补传', + sourceType: ScheduledTaskSourceType.http, + sourceName: '本地证据队列与 HTTP 图片接口', + ), + ScheduledTaskType.terminalLogUpload => const _ScheduledTaskDescriptor( + title: '终端日志上传', + sourceType: ScheduledTaskSourceType.file, + sourceName: '本地文件与 HTTP 上传接口', + ), + ScheduledTaskType.expiredLogCleanup => const _ScheduledTaskDescriptor( + title: '过期日志清理', + sourceType: ScheduledTaskSourceType.file, + sourceName: '本地日志文件', + ), + }; + + /// 将异常摘要截断到 1000 个字符,避免单条任务日志异常膨胀。 + static String _safeError(Object error) { + final text = error.toString(); + return text.length <= 1000 ? text : '${text.substring(0, 1000)}...'; + } + + /// 将异常堆栈截断到 4000 个字符,在保留诊断价值的同时限制 SQLite 记录大小。 + static String _safeStack(StackTrace stack) { + final text = stack.toString(); + return text.length <= 4000 ? text : '${text.substring(0, 4000)}...'; + } + + /// 幂等释放调度资源;并发调用会等待同一个正在执行的释放操作。 + Future dispose() async { + final disposing = _disposeFuture; + if (disposing != null) return disposing; + final operation = _dispose(); + _disposeFuture = operation; + try { + await operation; + } finally { + if (identical(_disposeFuture, operation)) _disposeFuture = null; } } + + /// 取消轮询和全部 Cron 句柄、清空待执行状态并关闭 Cron 实例。 + Future _dispose() async { + _started = false; + _idleTimer?.cancel(); + _idleTimer = null; + for (final task in _scheduledTasks.values) { + await task.cancel(); + } + _scheduledTasks.clear(); + _pendingRequests.clear(); + pendingTasks.clear(); + final cron = _cron; + _cron = null; + await cron?.close(); + } +} + +/// 尚未执行或同任务运行中再次触发时保存的合并请求。 +class _PendingTaskRequest { + /// 创建一次待执行请求,明确日志范围、空闲要求和触发来源。 + const _PendingTaskRequest({ + required this.currentLogOnly, + required this.requireSafeWindow, + required this.requireFullIdle, + required this.triggerType, + required this.faceUserIds, + required this.fullFaceSnapshot, + required this.debugAllFaces, + }); + + /// 是否只上传 Logger 当前写入的日志文件;为假时扫描全部日志。 + final bool currentLogOnly; + + /// 是否必须等待支付、人脸、遮罩、弹窗和其他重型任务全部结束。 + final bool requireSafeWindow; + + /// 是否还需在安全窗口基础上满足连续 60 秒无人操作。 + final bool requireFullIdle; + + /// 最终写入任务执行日志的触发来源。 + final ScheduledTaskTriggerType triggerType; + + /// FACE 用户增量回查集合;部门完整快照时为空。 + final Set? faceUserIds; + + /// 当前请求是否为部门或调试完整快照,用于合并时让全量覆盖用户增量。 + final bool fullFaceSnapshot; + + /// 是否执行仅非生产可触发的全租户调试快照。 + final bool debugAllFaces; + + /// 全量日志请求优先于“仅当前文件”;任一手动请求到达后只需等待安全窗口。 + _PendingTaskRequest merge(_PendingTaskRequest other) { + final mergedFullFaceSnapshot = fullFaceSnapshot || other.fullFaceSnapshot; + final mergedUserIds = mergedFullFaceSnapshot + ? null + : {...?faceUserIds, ...?other.faceUserIds}; + return _PendingTaskRequest( + currentLogOnly: currentLogOnly && other.currentLogOnly, + requireSafeWindow: requireSafeWindow || other.requireSafeWindow, + // Cron/补排全量一旦进入队列,不因随后到达的 MQ 增量而降低 60 秒空闲要求。 + requireFullIdle: mergedFullFaceSnapshot + ? requireFullIdle || other.requireFullIdle + : requireFullIdle && other.requireFullIdle, + triggerType: triggerType == ScheduledTaskTriggerType.cron + ? other.triggerType + : triggerType, + faceUserIds: + mergedUserIds == null ? null : Set.unmodifiable(mergedUserIds), + fullFaceSnapshot: mergedFullFaceSnapshot, + debugAllFaces: debugAllFaces || other.debugAllFaces, + ); + } +} + +/// MQTT 等外部依赖暂不可用时使用的可诊断任务异常。 +class ScheduledTaskDependencyException implements Exception { + /// 使用面向终端人员的安全摘要创建依赖异常。 + const ScheduledTaskDependencyException(this.message); + + /// 不包含地址、凭证或堆栈的依赖失败摘要。 + final String message; + + /// 返回可直接写入任务最近结果的依赖失败摘要。 + @override + String toString() => message; +} + +/// 单个业务动作完成后返回给统一调度收尾流程的结果。 +class _ScheduledTaskActionResult { + /// 创建成功结果,可附带文件任务的上传统计。 + const _ScheduledTaskActionResult.success(this.message, {this.summary}) + : status = ScheduledTaskRunStatus.success; + + /// 创建无需继续处理的跳过结果,不携带文件上传统计。 + const _ScheduledTaskActionResult.skipped(this.message) + : status = ScheduledTaskRunStatus.skipped, + summary = null; + + /// 设置页和任务日志使用的简短中文结果摘要。 + final String message; + + /// 本次动作的最终运行状态,仅包含成功或跳过。 + final ScheduledTaskRunStatus status; + + /// 日志上传或清理任务的文件统计,其他任务为空。 + final TerminalLogUploadSummary? summary; +} + +/// 任务展示名称及主要依赖来源的稳定描述。 +class _ScheduledTaskDescriptor { + /// 创建一项任务元数据,仅用于展示和结构化日志分类。 + const _ScheduledTaskDescriptor({ + required this.title, + required this.sourceType, + required this.sourceName, + }); + + /// 面向终端人员的中文任务名称。 + final String title; + + /// MQTT、HTTP、插件、文件或本地逻辑中的主要依赖分类。 + final ScheduledTaskSourceType sourceType; + + /// 执行日志展示的具体依赖名称快照。 + final String sourceName; } diff --git a/lib/common/utils/sync/sync_face_tool.dart b/lib/common/utils/sync/sync_face_tool.dart index 7b6727d..014080b 100644 --- a/lib/common/utils/sync/sync_face_tool.dart +++ b/lib/common/utils/sync/sync_face_tool.dart @@ -1,259 +1,846 @@ import 'dart:io'; -import 'package:common_utils/common_utils.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; -class SyncFaceTool { - static const _tag = "sync人脸工具"; +/// 应用级人脸差异同步服务,供页面、Cron、MQ 和识别异常恢复共同复用。 +abstract class SyncFaceTool { + /// 人脸同步日志标签;日志只记录汇总和脱敏样本,不记录姓名、图片地址或特征。 + static const String _tag = '人脸差异同步'; - // 删除数据库中为空的人脸 + /// V3 游标快照页面大小。 + static const int _snapshotPageSize = 200; + + /// V2 调试全租户同步页面大小,仅非生产入口使用。 + static const int _debugPageSize = 100; + + /// 单次日志最多保留的脱敏失败样本数量,避免异常数据导致日志膨胀。 + static const int _maxFailureSamples = 20; + + /// V3 游标保护页数,避免异常服务端持续返回 hasMore 造成无限循环。 + static const int _maxSnapshotPages = 10000; + + /// 服务端单次允许接收的人脸质量结果上限。 + static const int _qualityReportBatchSize = 500; + + /// 删除数据库中缺少本地特征或图片的历史残缺人脸。 + /// + /// 删除顺序仍先处理虹软内存库;单条失败时保留记录供后续权威快照重试。 static Future delEmptyFace() async { - final app = Get.find(); - final faceDao = app.dataBase.getFaceDao; - final faceList = await faceDao.getEmptyFaceList(); - if (ObjectUtil.isEmptyList(faceList)) return; - for (final v in faceList!) { - if (ObjectUtil.isNotEmpty(v.path)) { - await delFaceByStorage(filePath: v.path!); - } - await faceDao.delById(v.id!); + final faceDao = Get.find().dataBase.getFaceDao; + final faces = await faceDao.getEmptyFaceList() ?? const []; + for (final face in faces) { + await _deleteLocalFace(face, faceDao); } } - // 同步人脸 - static Future syncFace({ + /// 兼容既有页面与识别恢复调用的统一入口。 + /// + /// [isAll] 只允许非生产调试使用;生产调用即使误传也会收敛为本部门快照。 + static Future syncFace({ bool isAll = false, VoidCallback? onSyncGroup, }) async { - final appSetting = await AppSettingTool.getSysSetting(); - final merchantDeptId = appSetting.merchantDeptId; - // 人脸存储路径 + final mode = isAll && !AppSysTool.isProd() + ? FaceSyncMode.debugAllSnapshot + : FaceSyncMode.departmentSnapshot; + final summary = await sync(mode: mode); + onSyncGroup?.call(); + return summary; + } + + /// 执行部门权威快照或一组用户增量;用户集合按稳定顺序串行处理。 + /// + /// 完整快照会一次性拉入内存并完成唯一性、游标和页面连续性校验, + /// 任何页面中断都会抛出异常且不会进入本地孤儿删除阶段。 + static Future sync({ + required FaceSyncMode mode, + Set? userIds, + }) async { + if (!AppNetworkTool.isAutoNetwork()) { + throw const FaceSyncSnapshotException('网络不可用,无法获取人脸权威快照'); + } + final setting = await AppSettingTool.getSysSetting(); + final merchantDeptId = setting.merchantDeptId?.trim(); + if (merchantDeptId == null || merchantDeptId.isEmpty) { + throw const FaceSyncSnapshotException('终端尚未绑定商户部门'); + } final storagePath = await AppFileTool.getFacePath(); - if (ObjectUtil.isEmpty(storagePath)) { - logger.e("|$_tag|同步人脸错误,创建人脸存储路径错误"); + if (storagePath == null || storagePath.isEmpty) { + throw const FaceSyncSnapshotException('无法创建人脸本地存储目录'); + } + if (!AppRuntimeTool.useManualHardwareInput && + AppSysTool.isProd() && + !await AppFaceTool.isActiveSdk()) { + throw const FaceSyncSnapshotException('人脸 SDK 未激活,无法安全更新人脸库'); + } + + final accumulator = _FaceSyncAccumulator(mode); + if (mode == FaceSyncMode.userDelta) { + final normalizedUserIds = (userIds ?? const {}) + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .toSet() + .toList() + ..sort(); + if (normalizedUserIds.isEmpty) { + throw const FaceSyncSnapshotException('用户增量同步缺少用户标识'); + } + for (final userId in normalizedUserIds) { + final remote = await _loadV3Snapshot( + merchantDeptId: merchantDeptId, + userId: userId, + ); + if (remote.length > 3) { + throw FaceSyncSnapshotException( + '用户 ${_maskSampleId(userId)} 的服务端人脸超过3张,已停止本次增量', + ); + } + final local = + await Get.find().dataBase.getFaceDao.findByUserId( + userId, + ) ?? + const []; + await _reconcile( + remote: remote, + local: local, + storagePath: storagePath, + accumulator: accumulator, + ); + } + } else { + final remote = mode == FaceSyncMode.debugAllSnapshot + ? await _loadDebugAllSnapshot() + : await _loadV3Snapshot(merchantDeptId: merchantDeptId); + final local = await Get.find().dataBase.getFaceDao.getAll(); + await _reconcile( + remote: remote, + local: local, + storagePath: storagePath, + accumulator: accumulator, + ); + } + await _reportPersistedFaceQualitySafely(); + final summary = accumulator.toSummary(); + logger.i('|$_tag|同步完成|${summary.message}|'); + return summary; + } + + /// 拉取 V3 固定上界游标快照并校验页面连续性、记录唯一性和字段有效性。 + static Future> _loadV3Snapshot({ + required String merchantDeptId, + String? userId, + }) async { + var afterId = '0'; + String? snapshotMaxId; + final records = []; + final databaseIds = {}; + final sampleIds = {}; + for (var page = 0; page < _maxSnapshotPages; page++) { + final snapshot = await UserApi.getFaceSyncSnapshot({ + 'merchantDeptId': merchantDeptId, + 'userId': userId, + 'afterId': afterId, + 'limit': _snapshotPageSize, + 'snapshotMaxId': snapshotMaxId, + }); + snapshotMaxId ??= snapshot.snapshotMaxId; + if (snapshot.snapshotMaxId != snapshotMaxId) { + throw const FaceSyncSnapshotException('人脸快照上界在分页期间发生变化'); + } + var previousId = int.tryParse(afterId) ?? 0; + for (final record in snapshot.records) { + final currentId = int.tryParse(record.id); + final sampleId = int.tryParse(record.sampleId); + if (currentId == null || currentId <= previousId) { + throw const FaceSyncSnapshotException('人脸快照游标不连续或发生倒退'); + } + if (sampleId == null || sampleId <= 0 || sampleId > 2147483647) { + throw FaceSyncSnapshotException( + '存在无效人脸编号 ${_maskSampleId(record.sampleId)}', + ); + } + if (!databaseIds.add(record.id) || !sampleIds.add(record.sampleId)) { + throw FaceSyncSnapshotException( + '人脸快照包含重复编号 ${_maskSampleId(record.sampleId)}', + ); + } + if (userId != null && record.userId != userId) { + throw const FaceSyncSnapshotException('用户增量快照返回了其他用户数据'); + } + if (record.faceImg == null || record.faceImg!.trim().isEmpty) { + throw FaceSyncSnapshotException( + '人脸 ${_maskSampleId(record.sampleId)} 缺少图片地址', + ); + } + records.add(record); + previousId = currentId; + } + final expectedNext = + snapshot.records.isEmpty ? afterId : snapshot.records.last.id; + if (snapshot.nextAfterId != expectedNext) { + throw const FaceSyncSnapshotException('人脸快照下一页游标与当前页不一致'); + } + if (!snapshot.hasMore) return records; + if (snapshot.records.isEmpty) { + throw const FaceSyncSnapshotException('人脸快照存在空页但仍声明后续数据'); + } + afterId = snapshot.nextAfterId; + } + throw const FaceSyncSnapshotException('人脸快照分页超过安全上限'); + } + + /// 使用保留的 V2 接口拉取非生产调试全租户快照。 + /// + /// 生产入口不会调用本方法;V2 不提供源更新时间,因此仅以图片地址判断图片版本。 + static Future> _loadDebugAllSnapshot() async { + final records = []; + final sampleIds = {}; + for (var page = 1; page <= _maxSnapshotPages; page++) { + final data = await UserApi.getFaceListV2({ + 'current': page, + 'size': _debugPageSize, + 'merchantDeptId': '', + }); + if (data == null) { + throw const FaceSyncSnapshotException('调试全租户人脸接口未返回有效数据'); + } + for (final face in data) { + final sampleId = face.autoCode?.trim(); + final userId = face.userId?.trim(); + if (sampleId == null || + userId == null || + sampleId.isEmpty || + userId.isEmpty || + !sampleIds.add(sampleId)) { + throw const FaceSyncSnapshotException('调试全租户快照包含无效或重复人脸编号'); + } + records.add(ServiceFaceSyncRecord( + id: face.id ?? sampleId, + sampleId: sampleId, + userId: userId, + username: face.username, + deptId: face.deptId ?? '', + deptName: face.deptName, + faceImg: face.faceImg, + )); + } + if (data.length < _debugPageSize) return records; + } + throw const FaceSyncSnapshotException('调试全租户快照分页超过安全上限'); + } + + /// 对完整服务端集合与指定范围本地集合做差异,只处理新增、变化和孤儿记录。 + static Future _reconcile({ + required List remote, + required List local, + required String storagePath, + required _FaceSyncAccumulator accumulator, + }) async { + final faceDao = Get.find().dataBase.getFaceDao; + final localBySampleId = {}; + for (final face in local) { + if (localBySampleId.containsKey(face.sampleId)) { + throw FaceSyncSnapshotException( + '本地存在重复人脸编号 ${_maskSampleId(face.sampleId)}', + ); + } + localBySampleId[face.sampleId] = face; + } + + for (final record in remote) { + accumulator.scanned++; + final existing = localBySampleId.remove(record.sampleId); + final imageChanged = await _imageChanged(existing, record); + if (existing == null || imageChanged) { + final success = await _upsertImageFace( + record: record, + existing: existing, + storagePath: storagePath, + faceDao: faceDao, + ); + if (success) { + existing == null ? accumulator.added++ : accumulator.updated++; + } else { + accumulator.fail(record.sampleId); + } + continue; + } + if (_metadataChanged(existing, record)) { + await faceDao.edit(_toEntity(record, existing: existing)); + accumulator.updated++; + } else { + accumulator.skipped++; + } + } + + // 只有远端完整集合已经成功拉取和校验后才会执行此处的集合差删除。 + for (final orphan in localBySampleId.values) { + if (await _deleteLocalFace(orphan, faceDao)) { + accumulator.deleted++; + } else { + accumulator.fail(orphan.sampleId); + } + } + } + + /// 判断图片版本、特征或正式文件是否变化;完全一致时不下载也不写虹软。 + static Future _imageChanged( + FaceEntity? existing, + ServiceFaceSyncRecord record, + ) async { + if (existing == null) return true; + if (existing.imagePath != record.faceImg) return true; + if (record.sourceUpdatedAt != null && + existing.updateTime != record.sourceUpdatedAt) { + return true; + } + if ((existing.faceModel ?? const []).isEmpty) return true; + final path = existing.path; + return path == null || path.isEmpty || !await File(path).exists(); + } + + /// 判断姓名或部门元数据是否变化;此类变化不需要重新提取图片特征。 + static bool _metadataChanged( + FaceEntity existing, + ServiceFaceSyncRecord record, + ) => + existing.userId != record.userId || + existing.userName != (record.username ?? '') || + existing.deptId != record.deptId || + existing.deptName != (record.deptName ?? ''); + + /// 下载临时图片、提取特征并提交文件、SQLite 和虹软单样本增量。 + /// + /// 虹软替换失败时原生保留旧特征,本方法随后回滚 SQLite 和新文件;新样本失败则不留正式记录。 + static Future _upsertImageFace({ + required ServiceFaceSyncRecord record, + required FaceEntity? existing, + required String storagePath, + required FaceDao faceDao, + }) async { + File? temporaryFile; + File? finalFile; + var databaseChanged = false; + try { + temporaryFile = await _downloadTemporaryFace(record, storagePath); + final extraction = AppRuntimeTool.useManualHardwareInput + ? null + : await FaceDiscernPlugs.extractFaceFeature( + filePath: temporaryFile.path, + ); + final featureData = extraction?.featureData ?? []; + if (!AppRuntimeTool.useManualHardwareInput && featureData.isEmpty) { + throw const FaceSyncSnapshotException('图片未提取到可注册人脸'); + } + + finalFile = File(_officialFilePath(record, storagePath)); + if (await finalFile.exists()) await finalFile.delete(); + await temporaryFile.rename(finalFile.path); + temporaryFile = null; + final entity = _toEntity( + record, + existing: existing, + path: finalFile.path, + faceModel: featureData, + extraction: extraction, + imageReplaced: true, + ); + if (existing == null) { + final id = await faceDao.save(entity); + if (id == null) throw const FaceSyncSnapshotException('本地人脸记录写入失败'); + entity.id = id; + } else { + await faceDao.edit(entity); + } + databaseChanged = true; + + if (!AppRuntimeTool.useManualHardwareInput) { + final applied = await FaceDiscernPlugs.applyFaceDelta( + operation: 'upsert', + sampleId: record.sampleId, + userId: record.userId, + username: record.username ?? '', + featureData: featureData, + ); + if (!applied.success) throw FaceSyncSnapshotException(applied.message); + } + if (existing?.path != null && existing!.path != finalFile.path) { + await delFaceByStorage(filePath: existing.path!); + } + return true; + } catch (error, stack) { + logger.w( + '|$_tag|样本处理失败sampleId:${_maskSampleId(record.sampleId)}|error:${error.runtimeType}|stack:$stack|', + ); + if (databaseChanged) { + try { + if (existing == null) { + await faceDao.delBySampleId(record.sampleId); + } else { + await faceDao.edit(existing); + } + } catch (rollbackError, rollbackStack) { + logger.e( + '|$_tag|回滚本地人脸失败sampleId:${_maskSampleId(record.sampleId)}|error:$rollbackError|stack:$rollbackStack|', + ); + } + } + if (finalFile != null) await delFaceByStorage(filePath: finalFile.path); + return false; + } finally { + if (temporaryFile != null) { + await delFaceByStorage(filePath: temporaryFile.path); + } + } + } + + /// 删除本地孤儿人脸;文件、虹软或 SQLite 失败时恢复文件和旧特征。 + static Future _deleteLocalFace( + FaceEntity face, + FaceDao faceDao, + ) async { + File? originalFile; + File? pendingFile; + try { + if (face.path != null && face.path!.isNotEmpty) { + originalFile = File(face.path!); + if (await originalFile.exists()) { + pendingFile = File('${face.path}.pending-delete'); + if (await pendingFile.exists()) await pendingFile.delete(); + await originalFile.rename(pendingFile.path); + } + } + if (!AppRuntimeTool.useManualHardwareInput) { + final removed = await FaceDiscernPlugs.applyFaceDelta( + operation: 'delete', + sampleId: face.sampleId, + ); + if (!removed.success) throw FaceSyncSnapshotException(removed.message); + } + await faceDao.delBySampleId(face.sampleId); + if (pendingFile != null && await pendingFile.exists()) { + await pendingFile.delete(); + } + return true; + } catch (error, stack) { + logger.w( + '|$_tag|删除样本失败sampleId:${_maskSampleId(face.sampleId)}|error:${error.runtimeType}|stack:$stack|', + ); + try { + if (pendingFile != null && await pendingFile.exists()) { + await pendingFile.rename(originalFile!.path); + } + final stillExists = await faceDao.findBySampleId(face.sampleId); + if (stillExists == null) await faceDao.save(face); + await _restoreNativeFace(face); + } catch (rollbackError, rollbackStack) { + logger.e( + '|$_tag|回滚删除失败sampleId:${_maskSampleId(face.sampleId)}|error:$rollbackError|stack:$rollbackStack|', + ); + } + return false; + } + } + + /// 通过与同步孤儿删除相同的可回滚流程清理一条已获服务端确认的人脸。 + /// + /// 调用方必须先完成服务端删除或取得“服务端已不存在”确认;失败时本方法恢复 + /// 图片、SQLite 和虹软内存特征,并返回 false 供页面保留重试入口。 + static Future deleteLocalFace(FaceEntity face) => + _deleteLocalFace(face, Get.find().dataBase.getFaceDao); + + /// 手动检测存量在库照片质量并将结果批量回写 SQLite。 + /// + /// [failedOnly] 为 true 时仅重试上次失败记录;不存在或不可读的本地文件会直接 + /// 标记为 failed,其他图片交给 Android 原生单线程质量任务处理。 + static Future auditLibraryFaceQuality({ + bool failedOnly = false, + }) async { + final faceDao = Get.find().dataBase.getFaceDao; + final allCandidates = await faceDao.findQualityAuditCandidates(); + final candidates = failedOnly + ? allCandidates + .where( + (face) => face.qualityStatus == FaceQualityStatus.failed.code, + ) + .toList(growable: false) + : allCandidates; + if (candidates.isEmpty) { + final reported = await _reportPersistedFaceQualitySafely(); + return FaceQualityAuditResult( + results: [], + message: reported ? '没有需要检测的人脸' : '没有需要检测的人脸;服务端上报失败,将在下次重试', + ); + } + + final invalidResults = []; + final validFaces = []; + for (final face in candidates) { + final path = face.path; + if (path == null || path.isEmpty || !await File(path).exists()) { + invalidResults.add(FaceImageQualityResult( + sampleId: face.sampleId, + status: FaceQualityStatus.failed, + message: '本地人脸图片不存在', + errorCode: -2, + )); + } else { + validFaces.add(face); + } + } + + FaceQualityAuditResult nativeResult = const FaceQualityAuditResult( + results: [], + message: '在库人脸质量检测完成', + ); + if (validFaces.isNotEmpty) { + try { + nativeResult = await FaceDiscernPlugs.auditFaceQuality( + items: validFaces + .map( + (face) => FaceQualityAuditItem( + sampleId: face.sampleId, + filePath: face.path!, + ), + ) + .toList(growable: false), + ); + } catch (error, stack) { + logger.w('|$_tag|原生质量检测通道失败,将样本记录为检测失败|error:$error|stack:$stack|'); + nativeResult = FaceQualityAuditResult( + results: [ + for (final face in validFaces) + FaceImageQualityResult( + sampleId: face.sampleId, + status: FaceQualityStatus.failed, + message: '原生质量检测不可用', + errorCode: -99, + ), + ], + message: '原生质量检测不可用,样本已标记为检测失败', + ); + } + } + + final results = [ + ...invalidResults, + ...nativeResult.results, + ]; + final bySampleId = { + for (final result in results) result.sampleId: result, + }; + final checkedAt = DateTime.now(); + final updates = []; + for (final face in candidates) { + final result = bySampleId[face.sampleId] ?? + FaceImageQualityResult( + sampleId: face.sampleId, + status: FaceQualityStatus.failed, + message: '原生未返回该样本的质量结果', + errorCode: -99, + ); + face + ..qualityStatus = result.status.code + ..qualityScore = + result.status == FaceQualityStatus.detected ? result.score : null + ..qualityCheckedAt = checkedAt + ..qualityErrorCode = result.status == FaceQualityStatus.detected + ? null + : result.errorCode; + updates.add(face); + } + if (updates.isNotEmpty) await faceDao.batchEdit(updates); + final reported = await _reportPersistedFaceQualitySafely(); + return FaceQualityAuditResult( + results: [ + for (final face in candidates) + bySampleId[face.sampleId] ?? + FaceImageQualityResult( + sampleId: face.sampleId, + status: FaceQualityStatus.failed, + message: '原生未返回该样本的质量结果', + errorCode: -99, + ), + ], + message: reported + ? nativeResult.message + : '${nativeResult.message};服务端上报失败,将在下次重试', + ); + } + + /// 将本机已检测或检测失败的人脸质量按 500 条幂等补报到服务端。 + /// + /// 第一版不增加本地“已上报”数据库字段,因此每次完整同步或人工质量检测完成后 + /// 都会补报当前持久化结果。服务端按图片版本、成功状态优先级和检测时间保证幂等; + /// 任一网络或业务错误只记录汇总日志并返回 false,绝不回滚 SQLite、特征或虹软内存库。 + static Future _reportPersistedFaceQualitySafely() async { + if (!AppNetworkTool.isAutoNetwork()) { + logger.w('|$_tag|质量上报跳过|reason:网络不可用|'); + return false; + } + try { + final faces = await Get.find().dataBase.getFaceDao.getAll(); + final items = []; + var invalidCount = 0; + for (final face in faces) { + final status = FaceQualityStatus.fromCode(face.qualityStatus); + if (status == FaceQualityStatus.pending) continue; + final checkedAt = face.qualityCheckedAt; + final sourceUpdatedAt = face.updateTime; + final score = face.qualityScore; + if (checkedAt == null || + sourceUpdatedAt == null || + (status == FaceQualityStatus.detected && + (score == null || score < 0 || score > 1))) { + invalidCount++; + continue; + } + items.add(ServiceFaceQualityReportItem( + sampleId: face.sampleId, + status: status.code, + score: status == FaceQualityStatus.detected ? score : null, + sourceUpdatedAt: sourceUpdatedAt, + checkedAt: checkedAt, + errorCode: + status == FaceQualityStatus.failed ? face.qualityErrorCode : null, + )); + } + if (items.isEmpty) { + if (invalidCount > 0) { + logger.w('|$_tag|质量上报无有效结果|invalid:$invalidCount|'); + return false; + } + return true; + } + + final lease = await UserApi.acquireTerminalFaceQualityLease(); + if (!lease.granted) { + logger.i( + '|$_tag|质量上报跳过|reason:其他设备持有租约|remainingSeconds:${lease.remainingSeconds}|', + ); + return true; + } + + var allSucceeded = invalidCount == 0; + var confirmedCount = 0; + var failedBatchCount = 0; + for (var offset = 0; + offset < items.length; + offset += _qualityReportBatchSize) { + final end = + (offset + _qualityReportBatchSize).clamp(0, items.length).toInt(); + final batch = items.sublist(offset, end); + try { + final result = await UserApi.reportTerminalFaceQuality(batch); + final batchConfirmed = result.updatedSampleIds.length + + result.ignoredStaleSampleIds.length + + result.alreadyAbsentSampleIds.length; + confirmedCount += batchConfirmed; + if (batchConfirmed != batch.length) allSucceeded = false; + } catch (error, stack) { + allSucceeded = false; + failedBatchCount++; + logger.w( + '|$_tag|质量上报批次失败|size:${batch.length}|error:${error.runtimeType}|stack:$stack|', + ); + } + } + logger.i( + '|$_tag|质量上报完成|total:${items.length}|confirmed:$confirmedCount|invalid:$invalidCount|failedBatch:$failedBatchCount|', + ); + return allSucceeded; + } catch (error, stack) { + logger.w('|$_tag|质量上报准备失败|error:${error.runtimeType}|stack:$stack|'); + return false; + } + } + + /// 删除回滚时恢复旧内存特征;历史记录无特征时按用户从仍存在的 SQLite 重新装载。 + static Future _restoreNativeFace(FaceEntity face) async { + if (AppRuntimeTool.useManualHardwareInput) return; + final featureData = (face.faceModel ?? const []) + .map((item) => (item as num).toInt()) + .toList(); + if (featureData.isNotEmpty) { + await FaceDiscernPlugs.applyFaceDelta( + operation: 'upsert', + sampleId: face.sampleId, + userId: face.userId, + username: face.userName, + featureData: featureData, + ); return; } - // 同步没有的人脸 - int pageNo = 1; - bool isNext = true; - while (isNext) { - final bool = await syncGroupFace( - pageNo: pageNo, - merchantDeptId: isAll ? "" : merchantDeptId!, - storagePath: storagePath!, + await FaceDiscernPlugs.rebuildFaceLibrary(userId: face.userId); + } + + /// 下载远端图片到专用临时目录;下载失败不会覆盖任何正式文件。 + static Future _downloadTemporaryFace( + ServiceFaceSyncRecord record, + String storagePath, + ) async { + final imageUrl = record.faceImg!; + final bytes = await AppNetworkTool.downloadImage(imageUrl); + if (bytes == null || bytes.isEmpty) { + throw const FaceSyncSnapshotException('人脸图片下载失败'); + } + final directory = Directory('$storagePath/.face-sync-temp'); + if (!await directory.exists()) await directory.create(recursive: true); + final file = File( + '${directory.path}/${record.sampleId}_${DateTime.now().microsecondsSinceEpoch}.tmp', + ); + await file.writeAsBytes(bytes, flush: true); + return file; + } + + /// 生成人脸正式文件路径;版本戳确保新文件写入前旧文件始终可回滚。 + static String _officialFilePath( + ServiceFaceSyncRecord record, + String storagePath, + ) { + final extension = AppFileTool.getFileExtension(record.faceImg!); + final sourceVersion = record.sourceUpdatedAt?.millisecondsSinceEpoch ?? 0; + final writeVersion = DateTime.now().microsecondsSinceEpoch; + return '$storagePath/${record.userId}_${record.sampleId}_${sourceVersion}_$writeVersion$extension'; + } + + /// 将服务端快照合并为 Floor 现有 FaceEntity,不改变 SQLite 字段结构。 + static FaceEntity _toEntity( + ServiceFaceSyncRecord record, { + required FaceEntity? existing, + String? path, + List? faceModel, + FaceFeatureExtractionResult? extraction, + bool imageReplaced = false, + }) => + FaceEntity( + id: existing?.id, + userId: record.userId, + sampleId: record.sampleId, + userName: record.username ?? '', + deptId: record.deptId, + deptName: record.deptName ?? '', + imagePath: record.faceImg ?? '', + path: path ?? existing?.path, + faceModel: faceModel ?? existing?.faceModel, + createTime: existing?.createTime ?? DateTime.now(), + updateTime: record.sourceUpdatedAt ?? existing?.updateTime, + qualityScore: extraction == null + ? imageReplaced + ? null + : existing?.qualityScore + : extraction.qualityStatus == FaceQualityStatus.detected + ? extraction.qualityScore + : null, + qualityStatus: extraction?.qualityStatus.code ?? + (imageReplaced ? FaceQualityStatus.pending.code : null) ?? + existing?.qualityStatus ?? + FaceQualityStatus.pending.code, + qualityCheckedAt: extraction == null + ? imageReplaced + ? null + : existing?.qualityCheckedAt + : DateTime.now(), + qualityErrorCode: extraction == null + ? imageReplaced + ? null + : existing?.qualityErrorCode + : extraction.qualityStatus == FaceQualityStatus.detected + ? null + : extraction.qualityErrorCode, ); - if (isNext != bool) isNext = bool; - if (!bool) break; - pageNo += 1; - onSyncGroup?.call(); - } - } - static Future syncGroupFace({ - required int pageNo, - required String merchantDeptId, - required String storagePath, - String? userId, - bool? isGlobalFace, - }) async { - final app = Get.find(); - final faceDao = app.dataBase.getFaceDao; - const size = 100; - var pa = { - "current": pageNo, - "size": size, - "userId": userId, - "merchantDeptId": merchantDeptId, - }; - if (isGlobalFace != null && isGlobalFace) pa["merchantDeptId"] = null; - logger.i("|$_tag|同步人脸api参数:|$pa|"); - try { - final data = await UserApi.getFaceListV2(pa); - if (data == null) throw Error(); - if (ObjectUtil.isEmptyList(data)) { - if (ObjectUtil.isEmpty(userId)) return false; - // 删除人脸图片 - final faceList = await faceDao.findByUserId(userId!); - if (!ObjectUtil.isEmptyList(faceList)) { - for (final v in faceList!) { - if (ObjectUtil.isNotEmpty(v.path)) { - await delFaceByStorage(filePath: v.path!); - await AppFaceTool.removeFace(sampleId: v.sampleId); - } - } - } - // 删除数据库 - await faceDao.delByUserId(userId); - return false; - } - if (isGlobalFace != null && !isGlobalFace) return false; - if (userId != null) { - final dbUserList = await faceDao.findByUserId(userId); - if (!ObjectUtil.isEmptyList(dbUserList)) { - for (final v in dbUserList!) { - final target = data.firstWhereOrNull((i) => i.id == v.sampleId); - if (target == null) { - if (ObjectUtil.isNotEmpty(v.path)) { - await delFaceByStorage(filePath: v.path!); - await AppFaceTool.removeFace(sampleId: v.sampleId); - } - await faceDao.delById(v.id!); - } - } - } - } - for (final v in data) { - if (ObjectUtil.isEmpty(v.autoCode) || ObjectUtil.isEmpty(v.userId)) { - logger.e( - "|$_tag|同步人脸跳过无效数据:|autoCode=${v.autoCode}|userId=${v.userId}|deptId=${v.deptId}|username=${v.username}|", - ); - continue; - } - - final sampleId = v.autoCode!; - final userId = v.userId!; - // 删除人脸 - if (v.delFlag == true) { - // 先查询数据库是否存在 - final detail = await faceDao.findBySampleId(sampleId); - if (detail == null) { - continue; - } - if (ObjectUtil.isNotEmpty(detail.path)) { - await delFaceByStorage(filePath: detail.path!); - } - await AppFaceTool.removeFace(sampleId: sampleId); - await faceDao.delBySampleId(sampleId); - continue; - } - final faceEntity = await faceDao.findBySampleId(sampleId); - if (faceEntity == null) { - final add = FaceEntity( - userId: userId, - sampleId: sampleId, - userName: v.username ?? "", - deptId: v.deptId, - deptName: v.deptName ?? "", - imagePath: v.faceImg ?? "", - createTime: DateTime.now(), - ); - final addId = await faceDao.save(add); - if (addId != null) { - String? fileName = generateFaceFileName( - userId: add.userId, - sampleId: add.sampleId, - username: add.userName, - facePath: add.imagePath, - ); - final filePath = await writeFaceByStorage( - storagePath: storagePath, - fileName: fileName, - facePath: add.imagePath, - ); - if (ObjectUtil.isNotEmpty(filePath)) { - add.id = addId; - add.updateTime = DateTime.now(); - add.path = filePath; - await faceDao.edit(add); - } - } - await AppFaceTool.removeFace(sampleId: add.sampleId); - await AppFaceTool.faceRegister( - userId: add.userId.toString(), - forceRegister: true, - ); - continue; - } - final edit = FaceEntity( - id: faceEntity.id!, - userId: userId, - sampleId: sampleId, - userName: v.username ?? "", - deptId: v.deptId, - deptName: v.deptName ?? "", - imagePath: v.faceImg ?? "", - createTime: DateTime.now(), - ); - if (edit == faceEntity) { - continue; - } - String? fileName = generateFaceFileName( - userId: edit.userId, - sampleId: edit.sampleId, - username: edit.userName, - facePath: edit.imagePath, - ); - final filePath = await writeFaceByStorage( - storagePath: storagePath, - facePath: edit.imagePath, - fileName: fileName, - ); - if (ObjectUtil.isNotEmpty(filePath)) { - if (ObjectUtil.isNotEmpty(edit.path)) { - await delFaceByStorage(filePath: edit.path!); - } - edit.path = filePath; - } - await faceDao.edit(edit); - // 重新生成人脸模型 - await AppFaceTool.removeFace(sampleId: edit.sampleId); - await AppFaceTool.faceRegister( - userId: edit.userId.toString(), - forceRegister: true, - ); - } - if (data.length >= size) return true; - } catch (e, stack) { - logger.e("|$_tag|同步人脸意外的错误error:|$e|stack:|$stack|"); - } - return false; - } - - /// 删除人脸图片 - static Future delFaceByStorage({ - required String filePath, - }) async { + /// 删除指定本地文件;失败只记录文件路径以外的异常类型,避免同步主流程被清理动作中断。 + static Future delFaceByStorage({required String filePath}) async { try { final file = File(filePath); - if (await file.exists()) { - await file.delete(); - } - } catch (e) { - logger.e("|$_tag|删除人脸图片意外的错误error:|$e|"); + if (await file.exists()) await file.delete(); + } catch (error, stack) { + logger.w('|$_tag|删除人脸文件失败|error:${error.runtimeType}|stack:$stack|'); } } + /// 为兼容其他历史调用保留的文件名生成方法;新差异同步使用带版本戳的内部路径。 static String? generateFaceFileName({ required String userId, required String sampleId, required String username, required String facePath, }) { - if (ObjectUtil.isEmpty(facePath)) return null; - return "${userId}_${sampleId}_$username${AppFileTool.getFileExtension(facePath)}"; + if (facePath.isEmpty) return null; + return '${userId}_${sampleId}_$username${AppFileTool.getFileExtension(facePath)}'; } - /// 写入人脸头像到本地磁盘 + /// 为兼容其他历史调用保留的下载方法;新差异同步使用临时文件校验后再提升为正式文件。 static Future writeFaceByStorage({ required String storagePath, required String? fileName, required String facePath, }) async { - if (ObjectUtil.isEmpty(fileName)) return null; - final storageDirectory = Directory(storagePath); - final file = File('${storageDirectory.path}/$fileName'); - if (await file.exists()) { - await file.delete(); - } - final bytesImg = await AppNetworkTool.downloadImage(facePath); - if (bytesImg == null) return null; - final writeFile = await file.writeAsBytes(bytesImg); - if (await writeFile.exists()) { - return file.path; - } - return null; + if (fileName == null || fileName.isEmpty) return null; + final bytes = await AppNetworkTool.downloadImage(facePath); + if (bytes == null || bytes.isEmpty) return null; + final file = File('$storagePath/$fileName'); + await file.writeAsBytes(bytes, flush: true); + return await file.exists() ? file.path : null; + } + + /// 对虹软搜索标识进行脱敏,日志不输出完整样本编号。 + static String _maskSampleId(String value) { + if (value.length <= 4) return '***$value'; + return '${value.substring(0, 1)}***${value.substring(value.length - 3)}'; } } + +/// 人脸同步内部可变统计器,单次任务结束后转换为不可变汇总。 +class _FaceSyncAccumulator { + /// 创建指定同步模式的空统计器。 + _FaceSyncAccumulator(this.mode); + + /// 本次同步业务模式。 + final FaceSyncMode mode; + + /// 已扫描服务端记录数。 + int scanned = 0; + + /// 成功新增样本数。 + int added = 0; + + /// 成功更新样本数。 + int updated = 0; + + /// 成功删除孤儿样本数。 + int deleted = 0; + + /// 无变化跳过样本数。 + int skipped = 0; + + /// 处理失败样本数。 + int failed = 0; + + /// 有限保存的脱敏失败样本。 + final List maskedFailedSampleIds = []; + + /// 累加一个失败样本并按上限保存脱敏编号。 + void fail(String sampleId) { + failed++; + if (maskedFailedSampleIds.length < SyncFaceTool._maxFailureSamples) { + maskedFailedSampleIds.add(SyncFaceTool._maskSampleId(sampleId)); + } + } + + /// 生成可公开给任务日志和页面的不可变汇总。 + FaceSyncSummary toSummary() => FaceSyncSummary( + mode: mode, + scanned: scanned, + added: added, + updated: updated, + deleted: deleted, + skipped: skipped, + failed: failed, + maskedFailedSampleIds: List.unmodifiable(maskedFailedSampleIds), + ); +} diff --git a/lib/common/utils/sync/sync_idle_tool.dart b/lib/common/utils/sync/sync_idle_tool.dart new file mode 100644 index 0000000..6ff5638 --- /dev/null +++ b/lib/common/utils/sync/sync_idle_tool.dart @@ -0,0 +1,117 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/services/face_recognition_session_service.dart'; + +/// 同步调度与顶栏共用的空闲状态快照,避免界面自行拼装支付、弹窗和重型任务规则。 +class SyncIdleSnapshot { + /// 创建一次不可变的终端空闲判断快照。 + const SyncIdleSnapshot({ + required this.foreground, + required this.safeWindow, + required this.idle, + required this.inactiveFor, + required this.reason, + }); + + /// 应用当前是否处于 resumed 前台状态。 + final bool foreground; + + /// 当前是否避开了支付遮罩、人脸会话、业务弹窗和其他重型任务。 + final bool safeWindow; + + /// 当前是否同时满足安全窗口和连续 60 秒无人操作。 + final bool idle; + + /// 距离最近一次指针操作或应用恢复前台经过的时长。 + final Duration inactiveFor; + + /// 当前未达到空闲状态的首要中文原因;空闲时为空字符串。 + final String reason; + + /// 返回顶栏使用的简短空闲状态文案。 + String get label => idle ? '空闲' : '忙碌'; +} + +/// 统一记录终端用户活动和重型后台任务,用于选择不会干扰支付、人脸和弹框的安全执行窗口。 +class SyncIdleTool { + /// 隐藏构造器,空闲判断必须通过全局单例维护同一份活动时间和任务计数。 + SyncIdleTool._(); + + /// 应用级空闲检测单例。 + static final SyncIdleTool instance = SyncIdleTool._(); + + /// 自动空闲任务要求的最短连续无人操作时间。 + static const Duration idleDuration = Duration(seconds: 60); + + /// 最近一次用户指针操作或恢复前台的本地时间。 + DateTime _lastActivityAt = DateTime.now(); + + /// 应用是否处于可安全执行终端任务的前台生命周期。 + bool _foreground = true; + + /// 当前并行执行的重型同步任务数量,用计数支持嵌套开始和结束。 + int _heavyTaskCount = 0; + + /// 记录一次终端触控活动并重新开始 60 秒空闲计时。 + void recordUserActivity() { + _lastActivityAt = DateTime.now(); + } + + /// 根据应用生命周期更新前台状态,恢复前台时视为一次用户活动。 + void updateLifecycle(AppLifecycleState state) { + _foreground = state == AppLifecycleState.resumed; + if (_foreground) recordUserActivity(); + } + + /// 标记一个重型任务开始,阻止其他需要安全窗口的任务并发执行。 + void beginHeavyTask() => _heavyTaskCount++; + + /// 标记一个重型任务结束;计数最低保持为零以容忍重复清理。 + void endHeavyTask() { + if (_heavyTaskCount > 0) _heavyTaskCount--; + } + + /// 手动任务和后台拉取只等待业务安全窗口,不额外要求 60 秒无人操作。 + SyncIdleSnapshot get snapshot { + final faceActive = Get.isRegistered() && + Get.find().isActive; + final route = Get.context == null ? null : ModalRoute.of(Get.context!); + final dialogOpen = Get.isDialogOpen == true || + Get.isBottomSheetOpen == true || + route?.isCurrent == false; + final inactiveFor = DateTime.now().difference(_lastActivityAt); + final reason = !_foreground + ? '应用不在前台' + : EasyLoading.isShow + ? '前台操作进行中' + : faceActive + ? '人脸识别进行中' + : dialogOpen + ? '业务弹窗处理中' + : _heavyTaskCount > 0 + ? '后台同步进行中' + : inactiveFor < idleDuration + ? '最近有终端操作' + : ''; + final safeWindow = _foreground && + !EasyLoading.isShow && + !faceActive && + !dialogOpen && + _heavyTaskCount == 0; + return SyncIdleSnapshot( + foreground: _foreground, + safeWindow: safeWindow, + idle: safeWindow && inactiveFor >= idleDuration, + inactiveFor: inactiveFor, + reason: reason, + ); + } + + /// 当前是否满足手动执行和 MQTT 拉取所需的业务安全窗口。 + bool get isSafeWindow => snapshot.safeWindow; + + /// 当前是否满足自动空闲任务的完整空闲条件。 + bool get isIdle => snapshot.idle; +} diff --git a/lib/common/utils/sync/sync_print_tool.dart b/lib/common/utils/sync/sync_print_tool.dart index e076829..4614749 100644 --- a/lib/common/utils/sync/sync_print_tool.dart +++ b/lib/common/utils/sync/sync_print_tool.dart @@ -1,132 +1,290 @@ -import 'dart:convert'; +import 'dart:async'; import 'package:common_utils/common_utils.dart'; import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +/// 业务终端的 EY_PRINT 代理队列,负责双通道入队、版本幂等、串行 USB 重试和结果补报。 abstract class SyncPrintTool { - static const _tag = "sync打印工具"; + /// 打印队列诊断日志使用的稳定模块标签。 + static const _tag = 'sync打印工具'; + /// 是否已有串行打印循环正在执行,防止两个任务同时占用 USB 通道。 static bool _isPrint = false; + /// 是否已有打印结果补报正在执行,防止网络恢复和定时任务重复提交。 static bool _isSync = false; + /// 是否已有 HTTP 待办补拉正在执行,防止 MQTT 重连和页面恢复重复拉取。 + static bool _isPulling = false; + + /// 将 MQTT 与 HTTP 的并发入队串行化,避免唯一索引插入竞争。 + static Future _enqueueTail = Future.value(); + + /// 三次 USB 尝试前的等待时间:首次立即执行,后两次失败后分别等待 5 秒和 30 秒。 + /// + /// 方案同时限定“最多三次 USB 尝试”,因此不会再安排会形成第 4 次出纸的 120 秒重试。 + static const attemptDelays = [ + Duration.zero, + Duration(seconds: 5), + Duration(seconds: 30), + ]; + + /// 将 MQTT 或 HTTP 待办按服务端任务 ID 转换为唯一的本地任务。 + /// + /// [orderId] 是服务端订单 ID,[androidOrderId] 允许线上预约订单为空;[historyId] + /// 是本地唯一去重键,[retryVersion] 只有高于本地失败版本时才允许再次进入打印队列。 static Future addTask({ required String orderId, - required String androidOrderId, + String? androidOrderId, required String historyId, required String pic, + String printType = '1', + int printNo = 1, + int retryVersion = 0, }) async { - final appC = Get.find(); - final printHistoryDao = appC.dataBase.getPrintHistoryDao; - final saveObj = PrintHistoryEntity( - orderId: androidOrderId, - serviceOrderId: orderId, - servicePrintId: historyId, - invoiceUrl: pic, - status: PrintStatus.NONE, - createTime: DateTime.now(), + final operation = _enqueueTail.then((_) => _addTask( + orderId: orderId, + androidOrderId: androidOrderId, + historyId: historyId, + pic: pic, + printType: printType, + printNo: printNo, + retryVersion: retryVersion, + )); + // 队尾吞掉上一项异常以保证后续任务仍可入队,当前调用者仍等待原 operation 获得真实结果。 + _enqueueTail = operation.then( + (_) {}, + onError: (Object _, StackTrace __) {}, ); - await printHistoryDao.save(saveObj); - logger.i("|$_tag|添加到打印记录到本地数据库,json:|${saveObj.toJson()}|"); - await SyncPrintTool.doCheckPrint(); + await operation; } - // 检查打印 - static Future doCheckPrint() async { - if (_isPrint) { - logger.i("|$_tag|检查打印,正在打印中"); + /// 在串行入队区内执行任务新增或失败版本更新。 + static Future _addTask({ + required String orderId, + String? androidOrderId, + required String historyId, + required String pic, + required String printType, + required int printNo, + required int retryVersion, + }) async { + final dao = Get.find().dataBase.getPrintHistoryDao; + final existing = await dao.findByServicePrintId(historyId); + if (existing != null) { + if (!canApplyTaskVersion(existing, retryVersion)) { + if (retryVersion > existing.retryVersion && + existing.status != PrintStatus.ERROR) { + logger.w( + '|$_tag|忽略非失败任务的超前版本|task:$historyId|status:${existing.status.name}|'); + return; + } + logger.i('|$_tag|忽略重复打印任务|task:$historyId|version:$retryVersion|'); + return; + } + existing + ..orderId = androidOrderId?.trim().isNotEmpty == true + ? androidOrderId! + : orderId + ..serviceOrderId = orderId + ..invoiceUrl = pic + ..printType = printType + ..printNo = printNo + ..retryVersion = retryVersion + ..attemptCount = 0 + ..lastError = null + ..resultPending = false + ..status = PrintStatus.NONE + ..printTime = null + ..syncTime = null; + await dao.edit(existing); + } else { + await dao.save(PrintHistoryEntity( + orderId: androidOrderId?.trim().isNotEmpty == true + ? androidOrderId! + : orderId, + serviceOrderId: orderId, + servicePrintId: historyId, + invoiceUrl: pic, + printType: printType, + printNo: printNo, + retryVersion: retryVersion, + status: PrintStatus.NONE, + createTime: DateTime.now(), + )); + } + logger.i('|$_tag|打印任务已入队|task:$historyId|version:$retryVersion|'); + unawaited(doCheckPrint()); + } + + /// 只有本地任务已失败且服务端版本更高时,才允许同一任务重新出纸。 + static bool canApplyTaskVersion( + PrintHistoryEntity existing, int incomingVersion) => + existing.status == PrintStatus.ERROR && + incomingVersion > existing.retryVersion; + + /// 在登录、MQTT 重连、应用恢复和周期检查时补拉当前业务终端代理的待办。 + static Future pullPendingTasks() async { + if (_isPulling || !AppNetworkTool.isAutoNetwork()) { return; } - final appC = Get.find(); - final printHistoryDao = appC.dataBase.getPrintHistoryDao; - final printFacility = appC.printManage; + var setting = await AppSettingTool.getSysSetting(); + if (setting.serviceId == null) return; + _isPulling = true; try { - // 检查有没有需要打印的 - final list = await printHistoryDao.getAll(PrintStatus.NONE.name); - if (ObjectUtil.isEmptyList(list)) { - _isPrint = false; - logger.i("|$_tag|检查打印,没有需要打印的发票"); - return; + setting = await AppSettingTool.updatePrinterBinding( + await HardApi.getPrinterBinding(), + ); + if (setting.printerHardId == null) return; + final tasks = await HardApi.getPrinterTasks(); + for (final task in tasks) { + if (task.historyId == null || + task.orderId == null || + task.pic == null) { + continue; + } + await addTask( + orderId: task.orderId!, + androidOrderId: task.androidOrderCode, + historyId: task.historyId!, + pic: task.pic!, + printType: task.printType ?? '1', + printNo: task.printNo ?? 1, + retryVersion: task.retryVersion ?? 0, + ); } - final isOpen = await printFacility.open(); - if (!isOpen) { - _isPrint = false; - logger.e("|$_tag|检查打印,打开打印机失败"); - return; + } catch (error, stack) { + logger.w('|$_tag|补拉打印待办失败|error:$error|stack:$stack|'); + } finally { + _isPulling = false; + } + } + + /// 串行处理全部待打印任务,同一时间只允许一个 USB 写入流程。 + static Future doCheckPrint() async { + if (_isPrint) { + return; + } + _isPrint = true; + final dao = Get.find().dataBase.getPrintHistoryDao; + try { + // 循环查库可接住打印期间新入队的 MQTT/HTTP 任务,不必等待下一次十分钟补偿扫描。 + while (true) { + final list = await dao.getAll(PrintStatus.NONE.name); + if (list.isEmpty) { + break; + } + for (final task in list) { + await _print(task); + } } - for (final v in list) { - await doPrint(v); - } - } catch (e) { - logger.e("|$_tag|检查打印,打印错误,error:|$e|"); + } catch (error, stack) { + logger.e('|$_tag|打印队列执行异常|error:$error|stack:$stack|'); } finally { _isPrint = false; } } - // 检查同步 + /// 补报本地已经完成的成功或失败结果,断网不会导致再次出纸。 static Future doCheckSync() async { - if (_isSync) { - logger.i("|$_tag|检查打印,正在打印中"); + if (_isSync || !AppNetworkTool.isAutoNetwork()) { return; } - final app = Get.find(); - final printHistoryDao = app.dataBase.getPrintHistoryDao; - final list = await printHistoryDao.getAll(PrintStatus.SUCCESS.name); - if (ObjectUtil.isEmptyList(list)) { - _isSync = false; - return; - } - for (final v in list) { - final printPa = { - "orderId": v.serviceOrderId, - "historyId": v.servicePrintId, - }; - try { - final flag = await HardApi.printInvoice(printPa); - if (!flag) Exception("|$_tag|同步失败api错误params|${v.toJson()}|"); - v.status = PrintStatus.SYNC; - v.syncTime = DateTime.now(); - await printHistoryDao.edit(v); - } catch (e) { - logger.e("|$_tag|检查同步,打印错误,error:|$e|"); - v.status = PrintStatus.ERROR; - await printHistoryDao.edit(v); - } finally { - _isSync = false; + _isSync = true; + final dao = Get.find().dataBase.getPrintHistoryDao; + try { + for (final task in await dao.getPendingResults()) { + await _report(task); } + } finally { + _isSync = false; } } - // 执行打印 - static Future doPrint(PrintHistoryEntity v) async { + /// 串行执行单个任务的最多三次 USB 尝试,并在最终状态落库后补报服务端。 + static Future _print(PrintHistoryEntity task) async { final app = Get.find(); - final printHistoryDao = app.dataBase.getPrintHistoryDao; - final printFacility = app.printManage; - try { - final isPrint = await printFacility.printImg(v.invoiceUrl); - if (!isPrint) { - v.status = PrintStatus.ERROR; - await printHistoryDao.edit(v); - throw Exception("|$_tag|打印失败params|${v.toJson()}|"); + final dao = app.dataBase.getPrintHistoryDao; + for (var index = 0; index < attemptDelays.length; index++) { + final delay = attemptDelays[index]; + if (delay > Duration.zero) { + await Future.delayed(delay); } - // 更新本地数据库 - v.status = PrintStatus.SUCCESS; - v.printTime = DateUtil.formatDate(DateTime.now()); - await printHistoryDao.edit(v); - // 同步到服务端 - final printPa = { - "orderId": v.serviceOrderId, - "historyId": v.servicePrintId, - }; - await HardApi.printInvoice(printPa); - v.status = PrintStatus.SYNC; - v.syncTime = DateTime.now(); - await printHistoryDao.edit(v); - } catch (e) { - logger.i("|$_tag|执行打印,打印错误,error:|$e|"); + task.attemptCount = index + 1; + await dao.edit(task); + var opened = false; + try { + opened = await app.printManage.open(); + if (!opened) { + throw StateError(app.printManage.lastError ?? 'USB 打印机连接失败'); + } + final accepted = await app.printManage.printImg(task.invoiceUrl); + if (!accepted) { + throw StateError(app.printManage.lastError ?? 'USB 未完整接收打印数据'); + } + task + ..status = PrintStatus.SUCCESS + ..printTime = DateUtil.formatDate(DateTime.now()) + ..lastError = null + ..resultPending = true; + await dao.edit(task); + await _report(task); + return; + } catch (error, stack) { + task.lastError = _errorText(error); + await dao.edit(task); + logger.w( + '|$_tag|USB 打印尝试失败|task:${task.servicePrintId}|attempt:${task.attemptCount}|error:$error|stack:$stack|'); + } finally { + if (opened) { + await app.printManage.close(); + } + } + } + task + ..status = PrintStatus.ERROR + ..resultPending = true; + await dao.edit(task); + await _report(task); + } + + /// 向服务端提交当前重试版本的最终结果;失败时保留 resultPending 等待网络恢复补报。 + static Future _report(PrintHistoryEntity task) async { + if (!AppNetworkTool.isAutoNetwork()) { + return; + } + final success = + task.status == PrintStatus.SUCCESS || task.status == PrintStatus.SYNC; + try { + final accepted = await HardApi.reportPrinterResult( + taskId: task.servicePrintId, + orderId: task.serviceOrderId, + retryVersion: task.retryVersion, + success: success, + attemptCount: task.attemptCount, + errorMessage: success ? null : task.lastError, + ); + if (!accepted) { + throw StateError('服务端拒绝打印结果'); + } + task + ..resultPending = false + ..syncTime = DateTime.now(); + if (success) { + task.status = PrintStatus.SYNC; + } + await Get.find().dataBase.getPrintHistoryDao.edit(task); + } catch (error, stack) { + logger.w( + '|$_tag|打印结果等待补报|task:${task.servicePrintId}|error:$error|stack:$stack|'); } } + + /// 将本地异常压缩成最多 500 个字符的单行可上报文本,避免持久化堆栈或敏感上下文。 + static String _errorText(Object error) { + final text = error.toString().replaceAll(RegExp(r'\s+'), ' ').trim(); + return text.length <= 500 ? text : text.substring(0, 500); + } } diff --git a/lib/common/utils/sync/terminal_auto_update_tool.dart b/lib/common/utils/sync/terminal_auto_update_tool.dart new file mode 100644 index 0000000..4b1c7b4 --- /dev/null +++ b/lib/common/utils/sync/terminal_auto_update_tool.dart @@ -0,0 +1,283 @@ +import 'dart:io'; + +import 'package:background_downloader/background_downloader.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 自动更新检查的业务结果,未执行安装的正常条件以跳过状态返回。 +class TerminalAutoUpdateResult { + /// 创建一次自动更新检查结果。 + const TerminalAutoUpdateResult({ + required this.skipped, + required this.message, + }); + + /// 是否因无更新、未绑定或平台/网络条件不满足而安全跳过。 + final bool skipped; + + /// 写入任务最近结果和日志的中文摘要。 + final String message; +} + +/// 自动更新候选版本评估结果,区分可安装目标和不满足发布策略的安全跳过原因。 +class TerminalAutoUpdateDecision { + /// 创建一份包含最高优先级强制更新版本的评估结果。 + const TerminalAutoUpdateDecision.install(ServiceAppVersion this.target) + : skippedMessage = ''; + + /// 创建一份不执行安装并保留明确业务原因的评估结果。 + const TerminalAutoUpdateDecision.skip(this.skippedMessage) : target = null; + + /// 同平台、已发布、强制更新且版本码高于本机的最高版本;不满足时为空。 + final ServiceAppVersion? target; + + /// 没有安装目标时写入任务记录的原因;存在目标时为空字符串。 + final String skippedMessage; + + /// 是否已经选出允许自动安装的目标版本。 + bool get hasTarget => target != null; +} + +/// Android Root APK 全自动更新工具。 +abstract final class TerminalAutoUpdateTool { + /// 自动更新诊断日志使用的稳定模块标签。 + static const String _tag = '终端自动更新'; + + /// 应用私有支持目录下的固定子目录,更新确认或失败后删除 APK 文件。 + static const String _downloadDirectory = 'apk_updates'; + + /// 查询并安装最高的已发布新版本;安全前置条件不满足时不产生任何系统变更。 + static Future run() async { + if (!Platform.isAndroid) { + return const TerminalAutoUpdateResult( + skipped: true, + message: '非 Android 平台,本次跳过', + ); + } + final setting = await AppSettingTool.getSysSetting(); + if (setting.serviceId == null || setting.serviceId!.isEmpty) { + return const TerminalAutoUpdateResult( + skipped: true, + message: '终端尚未绑定,本次跳过', + ); + } + if (!AppNetworkTool.isAutoNetwork()) { + return const TerminalAutoUpdateResult( + skipped: true, + message: '网络不可用,本次跳过', + ); + } + + final package = await PackageInfo.fromPlatform(); + final currentVersionCode = int.tryParse(package.buildNumber) ?? 0; + final platform = AppPlatformEnum.terminalCode(setting.sysType); + final page = await AppApi.openCardList( + { + 'appUniqueCode': Constants.deviceUniqueCode, + 'platform': platform, + 'current': 1, + 'size': 8, + }, + silent: true, + ); + final decision = evaluateForcedUpdate( + versions: page?.records ?? const [], + currentVersionCode: currentVersionCode, + appUniqueCode: Constants.deviceUniqueCode, + platform: platform, + ); + if (!decision.hasTarget) { + return TerminalAutoUpdateResult( + skipped: true, + message: decision.skippedMessage, + ); + } + final target = decision.target!; + final targetVersionCode = target.versionCode!; + final downloadUrl = _resolveDownloadUrl(target.downloadUrl); + if (downloadUrl == null) { + throw StateError('最新版本缺少 APK 下载地址'); + } + + // 只有已经选出符合发布策略的新版本才检查 Root,避免无更新时产生无意义的授权动作。 + final root = await AppRuntimeTool.checkRootAccess(); + await AppSettingTool.setRootAccessResult(root); + if (!root.available) { + throw ScheduledTaskDependencyException('Root 不可用:${root.diagnostic}'); + } + + final task = DownloadTask( + url: downloadUrl, + filename: 'pj_xfj_build_$targetVersionCode.apk', + directory: _downloadDirectory, + baseDirectory: BaseDirectory.applicationSupport, + updates: Updates.statusAndProgress, + retries: 2, + ); + final apkPath = await task.filePath(); + final apkFile = File(apkPath); + if (await apkFile.exists()) await apkFile.delete(); + try { + // 进度回调频率由下载插件控制,按 10% 节点限流后写日志,避免大 APK 刷屏。 + var nextProgressToLog = 0; + final download = await FileDownloader().download( + task, + onProgress: (progress) { + if (!progress.isFinite || progress < 0 || progress > 1) return; + final percentage = (progress * 100).floor().clamp(0, 100).toInt(); + while (nextProgressToLog <= percentage && nextProgressToLog <= 100) { + logger.i( + '|$_tag|APK下载进度target:$targetVersionCode|progress:$nextProgressToLog%|', + ); + nextProgressToLog += 10; + } + }, + ); + if (download.status != TaskStatus.complete || !await apkFile.exists()) { + throw StateError('APK 下载失败(${download.status.name})'); + } + final validation = await AppRuntimeTool.validateApk( + path: apkPath, + expectedVersionCode: targetVersionCode, + ); + if (!validation.success) { + throw StateError('${validation.code}:${validation.message}'); + } + + // 必须先让服务端保留计划更新原因;请求失败时不进入可能导致进程退出的安装步骤。 + await TerminalHeartbeatTool.report( + scene: TerminalHeartbeatScene.autoUpdate, + throwOnError: true, + ); + final install = await AppRuntimeTool.installApkWithRoot( + path: apkPath, + expectedVersionCode: targetVersionCode, + ); + if (!install.success) { + throw StateError('${install.code}:${install.message}'); + } + return TerminalAutoUpdateResult( + skipped: false, + message: 'Build $targetVersionCode 安装命令已提交,等待新版本启动确认', + ); + } catch (error, stack) { + if (await apkFile.exists()) await apkFile.delete(); + logger + .e('|$_tag|更新失败target:$targetVersionCode|error:$error|stack:$stack|'); + rethrow; + } + } + + /// + /// 按自动任务协议选择版本码最高的强制更新版本。 + /// + /// 版本码只负责判断升级顺序;`status=1` 和 `isForce=1` 分别控制是否已经发布、是否允许 + /// 无人工确认自动安装。存在更高版本但策略不满足时返回具体原因,不能误报“已是最新”。 + static TerminalAutoUpdateDecision evaluateForcedUpdate({ + required Iterable versions, + required int currentVersionCode, + required String appUniqueCode, + required String platform, + }) { + final higherVersions = versions + .where( + (item) => + item.appUniqueCode == appUniqueCode && + item.platform == platform && + (item.versionCode ?? 0) > currentVersionCode, + ) + .toList() + ..sort(_compareVersionCodeDescending); + if (higherVersions.isEmpty) { + return const TerminalAutoUpdateDecision.skip('当前已是最新版本'); + } + + final publishedVersions = higherVersions + .where( + (item) => item.status == AppVersionStatusEnum.published.code, + ) + .toList(); + if (publishedVersions.isEmpty) { + return TerminalAutoUpdateDecision.skip( + '发现更高版本 ${_versionLabel(higherVersions.first)},但未处于已发布状态,本次跳过', + ); + } + + final forcedVersions = publishedVersions + .where((item) => item.isForce == true) + .toList() + ..sort(_compareVersionCodeDescending); + if (forcedVersions.isEmpty) { + return TerminalAutoUpdateDecision.skip( + '发现已发布的新版本 ${_versionLabel(publishedVersions.first)},但未标记为强制更新,本次跳过', + ); + } + return TerminalAutoUpdateDecision.install(forcedVersions.first); + } + + /// 按内部版本码倒序排列候选项,缺失版本码的异常数据按 0 处理。 + static int _compareVersionCodeDescending( + ServiceAppVersion left, + ServiceAppVersion right, + ) => + (right.versionCode ?? 0).compareTo(left.versionCode ?? 0); + + /// 组合任务日志使用的展示版本和内部 Build,缺失展示版本时仍保留可诊断的版本码。 + static String _versionLabel(ServiceAppVersion version) { + final versionName = version.versionName?.trim(); + final build = version.versionCode ?? 0; + return versionName == null || versionName.isEmpty + ? 'Build $build' + : '$versionName(Build $build)'; + } + + /// 启动时确认原生待更新记录,完成任务快照及被包替换中断的 running 日志。 + static Future recoverPendingUpdate() async { + final recovery = await AppRuntimeTool.consumePendingUpdate(); + if (!recovery.pending) return; + final type = ScheduledTaskType.autoUpdate; + final now = DateTime.now(); + final record = ScheduledTaskRecord( + lastRunAt: SyncCheckTool.instance.records[type]?.lastRunAt ?? now, + lastSuccessAt: recovery.success + ? now + : SyncCheckTool.instance.records[type]?.lastSuccessAt, + success: recovery.success, + message: recovery.message, + ); + SyncCheckTool.instance.records[type] = record; + await ScheduledTaskStorageTool.saveRecord( + type, + record, + recovery.success + ? ScheduledTaskRunStatus.success + : ScheduledTaskRunStatus.failed, + ); + final latest = await ScheduledTaskStorageTool.getLatestLog(type); + if (latest?.status == ScheduledTaskRunStatus.running.name) { + await ScheduledTaskStorageTool.finishLog( + latest!.finish( + nextStatus: recovery.success + ? ScheduledTaskRunStatus.success.name + : ScheduledTaskRunStatus.failed.name, + nextMessage: recovery.message, + at: now, + nextErrorType: recovery.success ? null : 'UpdateRecoveryFailed', + nextErrorMessage: recovery.success ? null : recovery.message, + ), + ); + } + if (recovery.success) await AppRuntimeTool.refreshKioskStatus(); + } + + /// 将后台文件键转换为实际下载地址,绝对 HTTP 地址保持不变。 + static String? _resolveDownloadUrl(String? source) { + final value = source?.trim(); + if (value == null || value.isEmpty) return null; + if (value.startsWith('http://') || value.startsWith('https://')) { + return value; + } + return '${Constants.ossUrl}/api/admin/sys-file/oss/file?fileName=$value'; + } +} diff --git a/lib/common/utils/sync/terminal_heartbeat_tool.dart b/lib/common/utils/sync/terminal_heartbeat_tool.dart new file mode 100644 index 0000000..5a88515 --- /dev/null +++ b/lib/common/utils/sync/terminal_heartbeat_tool.dart @@ -0,0 +1,168 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 终端心跳场景编码,与后端 TerminalHeartbeatSceneEnum 名称保持一致。 +enum TerminalHeartbeatScene { + /// 普通在线心跳,服务端收到后清除计划更新断开原因。 + online('ONLINE'), + + /// 自动更新安装前心跳,服务端在重启期间保留“自动更新重启”原因。 + autoUpdate('AUTO_UPDATE'); + + /// 写入心跳接口的固定协议编码。 + final String code; + + /// 创建一项跨端固定心跳场景。 + const TerminalHeartbeatScene(this.code); +} + +/// 自动更新前心跳失败的可展示异常,隐藏 Dio 协议细节并保留明确恢复动作。 +class TerminalHeartbeatException implements Exception { + /// 使用已经脱敏和限长的终端操作提示创建异常。 + const TerminalHeartbeatException(this.message); + + /// 可写入任务记录并直接展示给终端操作人员的短中文说明。 + final String message; + + /// 返回不含请求地址、堆栈和 Dio 实现细节的安全摘要。 + @override + String toString() => message; +} + +/// 食堂、超市 Android 终端心跳管理器。 +abstract final class TerminalHeartbeatTool { + /// 心跳诊断日志使用的稳定模块标签。 + static const String _tag = '终端心跳'; + + /// 服务端业务提示的最大展示长度,避免异常响应再次撑满 Toast 和任务列表。 + static const int _maxServiceMessageLength = 120; + + /// 终端在线阈值为 180 秒,客户端每 60 秒上报一次以容忍两次短暂失败。 + static const Duration _interval = Duration(seconds: 60); + + /// 当前进程唯一心跳计时器,应用控制器关闭时释放。 + static Timer? _timer; + + /// 启动独立心跳并立即上报一次;重复调用只会重建同一个计时器。 + static void start() { + _timer?.cancel(); + unawaited(report()); + _timer = Timer.periodic(_interval, (_) => unawaited(report())); + } + + /// 停止进程内心跳计时器。 + static void stop() { + _timer?.cancel(); + _timer = null; + } + + /// + /// 上报当前 APK、运行环境、Kiosk、Root 与外围硬件最新快照。 + /// + /// [throwOnError] 只供自动更新前的强一致场景使用;普通后台心跳失败仅写日志, + /// 不弹 Toast,也不影响收银流程。 + static Future report({ + TerminalHeartbeatScene scene = TerminalHeartbeatScene.online, + bool throwOnError = false, + }) async { + try { + if (!AppNetworkTool.isAutoNetwork()) return false; + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) return false; + final package = await PackageInfo.fromPlatform(); + final network = await AppRuntimeTool.getNetworkSnapshot(); + final performance = await AppRuntimeTool.getPerformanceSnapshot(); + final kiosk = await AppRuntimeTool.refreshKioskStatus(); + final devices = AppDeviceStatusTool.status.value; + final versionCode = int.tryParse(package.buildNumber); + await HardApi.terminalHeartbeat( + hardId: hardId, + data: { + 'apkVersionName': package.version, + 'apkVersionCode': versionCode, + 'scene': scene.code, + 'systemUptimeSeconds': performance.systemUptimeSeconds, + 'appUptimeSeconds': performance.appUptimeSeconds, + 'networkType': network.type, + 'availableStorageBytes': performance.storageAvailableBytes, + 'rootStatus': setting.rootAccessStatus.name, + 'deviceOwner': kiosk.deviceOwner, + 'kioskActive': kiosk.active, + 'deviceStatus': { + 'printer': devices.printerAvailable, + 'steelyard': devices.steelyardAvailable, + 'cardReader': devices.cardReaderAvailable, + 'subScreen': devices.subScreenAvailable, + }, + }, + ); + return true; + } catch (error, stack) { + logger.w('|$_tag|上报失败scene:${scene.code}|error:$error|stack:$stack|'); + if (throwOnError) { + throw TerminalHeartbeatException(_toUserMessage(error)); + } + return false; + } + } + + /// 将心跳异常转换为包含原因和恢复动作的短提示,原始异常仅保留在诊断日志中。 + static String _toUserMessage(Object error) { + final serviceMessage = _extractServiceMessage(error); + if (serviceMessage != null && + (serviceMessage.contains('设备不存在') || + serviceMessage.contains('不属于当前租户'))) { + return '更新状态上报失败:设备不存在或不属于当前租户,请在“设置 > 基本信息”重新绑定设备'; + } + if (serviceMessage != null) { + return '更新状态上报失败:$serviceMessage'; + } + if (error is DioException) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return '更新状态上报超时,请检查网络后重试'; + case DioExceptionType.connectionError: + case DioExceptionType.badCertificate: + return '更新状态上报失败:无法连接服务器,请检查网络后重试'; + case DioExceptionType.badResponse: + final statusCode = error.response?.statusCode; + return statusCode == null + ? '更新状态上报失败:服务端返回异常,请稍后重试' + : '更新状态上报失败:服务端返回 HTTP $statusCode,请稍后重试'; + case DioExceptionType.cancel: + return '更新状态上报已取消,请重新执行任务'; + case DioExceptionType.unknown: + break; + } + } + return '更新状态上报失败,请稍后重试'; + } + + /// 从统一响应或静默请求拒绝原因中提取服务端业务提示,并压缩空白与限制长度。 + static String? _extractServiceMessage(Object error) { + if (error is! DioException) return null; + final data = error.response?.data; + Object? rawMessage; + if (data is Map) rawMessage = data['msg']; + if (rawMessage == null && + error.type == DioExceptionType.badResponse && + error.error is String) { + rawMessage = error.error; + } + final message = rawMessage?.toString().trim().replaceAll( + RegExp(r'\s+'), + ' ', + ); + if (message == null || message.isEmpty) return null; + return message.length <= _maxServiceMessageLength + ? message + : '${message.substring(0, _maxServiceMessageLength)}...'; + } +} diff --git a/lib/common/utils/sync/terminal_log_upload_tool.dart b/lib/common/utils/sync/terminal_log_upload_tool.dart new file mode 100644 index 0000000..5820b64 --- /dev/null +++ b/lib/common/utils/sync/terminal_log_upload_tool.dart @@ -0,0 +1,316 @@ +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:dio/dio.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 单次日志上传存在部分文件失败时抛出的汇总异常。 +/// +/// 已成功或跳过的文件不会回滚,[summary] 供调度日志和设置页保留本次完整统计。 +class TerminalLogUploadException implements Exception { + /// 使用本次上传统计创建异常,调用方应读取 [summary] 而不是重新扫描文件。 + const TerminalLogUploadException(this.summary); + + /// 包含扫描、跳过、成功、失败数量及最近成功时间的本次任务统计。 + final TerminalLogUploadSummary summary; + + /// 返回不包含日志正文和文件路径的安全错误摘要。 + @override + String toString() => '终端日志上传存在 ${summary.failed} 个失败文件'; +} + +/// 脱敏日志上传器,所有文件串行处理,避免占满终端磁盘、CPU 和上行带宽。 +class TerminalLogUploadTool { + /// 禁止实例化;上传流程只通过静态入口串行处理本次扫描结果。 + TerminalLogUploadTool._(); + + /// 当前日志脱敏规则版本,随 multipart 元数据提交给服务端留存。 + static const String sanitizeVersion = 'v1'; + + /// 单次预检或文件上传请求的发送与响应超时时间。 + static const Duration requestTimeout = Duration(minutes: 2); + + /// 扫描并上传终端日志,返回本次处理统计。 + /// + /// [currentOnly] 为真时只处理 Logger 当前写入文件,供 MQTT 实时拉取使用;每个文件都先生成 + /// 脱敏副本并通过预检确认是否需要上传。空的脱敏副本会记为已处理并跳过网络请求,任一文件失败 + /// 不会阻断后续文件处理。 + static Future upload({ + bool currentOnly = false, + }) async { + if (!AppNetworkTool.isAutoNetwork()) { + throw StateError('当前网络不可用'); + } + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) { + throw StateError('终端尚未绑定,无法上传日志'); + } + + await flushCurrentAppLog(); + final catalog = await listAppLogFiles(); + final files = currentOnly + ? catalog.files.where((item) => item.isCurrent).toList() + : catalog.files.where((item) => item.exists).toList(); + final states = await FileUploadRecordTool.getSuccessfulStates(hardId); + final appVersion = (await PackageInfo.fromPlatform()).version; + var skipped = 0; + var succeeded = 0; + var failed = 0; + DateTime? lastSuccessAt; + + for (final item in files) { + final source = File(item.path); + if (!await source.exists()) continue; + final stat = await source.stat(); + final sourceSize = item.isCurrent ? stat.size : stat.size; + final previous = states[item.relativePath]; + if (previous != null && + previous.sourceSize == sourceSize && + previous.sourceModifiedTime == stat.modified) { + skipped++; + lastSuccessAt = _latest(lastSuccessAt, previous.uploadedAt); + continue; + } + + File? temporaryFile; + FileUploadRecordEntity? uploadAttempt; + try { + uploadAttempt = await FileUploadRecordTool.beginAttempt( + hardId: hardId, + relativePath: item.relativePath, + sourceSize: sourceSize, + sourceModifiedTime: stat.modified, + ); + temporaryFile = File( + '${Directory.systemTemp.path}/terminal-log-${DateTime.now().microsecondsSinceEpoch}.log', + ); + await sanitizeAppLogFile( + source: source, + target: temporaryFile, + maxSourceBytes: sourceSize, + ); + final sanitizedHash = + (await md5.bind(temporaryFile.openRead()).first).toString(); + final sanitizedSize = await temporaryFile.length(); + String? serverFileId; + if (sanitizedSize == 0) { + // 空日志没有可供后台查看的内容,本机仍保存成功快照,避免定时任务反复处理同一空文件。 + skipped++; + logger.i('|终端日志上传|空日志无需上传path:${item.relativePath}|'); + } else { + final needUpload = await _preflight( + hardId: hardId, + relativePath: item.relativePath, + fileSize: sanitizedSize, + fileHash: sanitizedHash, + ); + if (needUpload) { + serverFileId = await _uploadFile( + hardId: hardId, + relativePath: item.relativePath, + modifiedAt: stat.modified, + appVersion: appVersion, + file: temporaryFile, + ); + succeeded++; + } else { + skipped++; + } + } + final uploadedAt = DateTime.now(); + lastSuccessAt = _latest(lastSuccessAt, uploadedAt); + await FileUploadRecordTool.markSuccess( + attempt: uploadAttempt, + sanitizedHash: sanitizedHash, + sanitizedSize: sanitizedSize, + serverFileId: serverFileId, + ); + } catch (error, stack) { + failed++; + if (uploadAttempt != null) { + try { + await FileUploadRecordTool.markFailure( + attempt: uploadAttempt, + error: error, + ); + } catch (recordError, recordStack) { + // 上传异常是主错误,记录表写入失败只能补充告警,不能覆盖真实失败原因。 + logger.w( + '|终端日志上传|保存失败记录异常path:${item.relativePath}|' + 'error:$recordError|stack:$recordStack|', + ); + } + } + logger.e( + '|终端日志上传|文件处理失败path:${item.relativePath}|error:$error|stack:$stack|', + ); + } finally { + try { + if (temporaryFile != null && await temporaryFile.exists()) { + await temporaryFile.delete(); + } + } catch (error, stack) { + // 临时脱敏副本清理失败不能中断后续文件,但必须留下可诊断告警。 + logger.w( + '|终端日志上传|临时文件清理失败path:${temporaryFile?.path}|' + 'error:$error|stack:$stack|', + ); + } + } + } + + final summary = TerminalLogUploadSummary( + scanned: files.length, + skipped: skipped, + succeeded: succeeded, + failed: failed, + lastSuccessAt: lastSuccessAt, + ); + if (failed > 0) throw TerminalLogUploadException(summary); + return summary; + } + + /// 在可空的当前时间与候选时间中返回较晚值,用于汇总最近成功时间。 + static DateTime _latest(DateTime? current, DateTime candidate) => + current == null || candidate.isAfter(current) ? candidate : current; + + /// 向后端提交路径、脱敏文件大小和 MD5,返回是否仍需上传文件内容。 + static Future _preflight({ + required String hardId, + required String relativePath, + required int fileSize, + required String fileHash, + }) async { + final response = await _retryRequest( + () => HttpManage().dio.post( + '/hard/hardTerminalLogFile/ad/preflight', + data: { + 'relativePath': relativePath, + 'fileSize': fileSize, + 'fileHash': fileHash, + }, + options: Options( + headers: {'DEVICE-ID': hardId}, + sendTimeout: requestTimeout, + receiveTimeout: requestTimeout, + ), + ), + ); + final data = response.data; + if (data is! Map || data['needUpload'] is! bool) { + throw StateError('日志上传预检响应无效'); + } + return data['needUpload'] as bool; + } + + /// 以 multipart 上传单个脱敏日志,并返回后端 `hard_terminal_log_file.id`。 + static Future _uploadFile({ + required String hardId, + required String relativePath, + required DateTime modifiedAt, + required String appVersion, + required File file, + }) async { + final fileName = relativePath.replaceAll('\\', '/').split('/').last; + final response = await _retryRequest( + () => HttpManage().dio.post( + '/hard/hardTerminalLogFile/ad/upload', + data: FormData.fromMap({ + 'relativePath': relativePath, + 'logDate': _logDate(relativePath, modifiedAt), + 'fileModifiedTime': modifiedAt.toIso8601String(), + 'appVersion': appVersion, + 'sanitizeVersion': sanitizeVersion, + 'file': MultipartFile.fromFileSync(file.path, filename: fileName), + }), + options: Options( + headers: {'DEVICE-ID': hardId}, + contentType: 'multipart/form-data', + sendTimeout: requestTimeout, + receiveTimeout: requestTimeout, + ), + ), + ); + final data = response.data; + if (data is! Map || data['id'] == null) { + // 统一请求层会展示业务错误但不抛异常,上传器必须自行确认服务端确实返回了成功结果。 + throw StateError('日志上传响应无效'); + } + return data['id'].toString(); + } + + /// 对连接、收发超时、网络错误和 5xx 最多重试三次,业务错误直接向上抛出。 + static Future> _retryRequest( + Future> Function() action, + ) async { + Object? lastError; + for (var attempt = 1; attempt <= 3; attempt++) { + try { + return await action(); + } on DioException catch (error) { + lastError = error; + final transient = error.type == DioExceptionType.connectionTimeout || + error.type == DioExceptionType.sendTimeout || + error.type == DioExceptionType.receiveTimeout || + error.type == DioExceptionType.connectionError || + (error.response?.statusCode ?? 0) >= 500; + if (!transient || attempt == 3) rethrow; + await Future.delayed(Duration(seconds: attempt)); + } + } + throw StateError('日志上传失败:$lastError'); + } + + /// 从标准日志相对路径推导 `yyyy-MM-dd`,旧路径不匹配时回退到文件修改日期。 + static String _logDate(String relativePath, DateTime fallback) { + final match = RegExp(r'(\d{4})-(\d{2})/(\d{2})\.log$') + .firstMatch(relativePath.replaceAll('\\', '/')); + if (match == null) { + return '${fallback.year.toString().padLeft(4, '0')}-${fallback.month.toString().padLeft(2, '0')}-${fallback.day.toString().padLeft(2, '0')}'; + } + return '${match.group(1)}-${match.group(2)}-${match.group(3)}'; + } + + /// 清理前必须确认历史日志已经成功上传,未同步文件只告警、不删除。 + static Future cleanupExpiredLogs() async { + final catalog = await listAppLogFiles(); + final setting = await AppSettingTool.getSysSetting(); + final hardId = setting.serviceId; + if (hardId == null || hardId.isEmpty) return 0; + final states = await FileUploadRecordTool.getSuccessfulStates(hardId); + final expireBefore = DateTime( + DateTime.now().year, + DateTime.now().month - 3, + DateTime.now().day, + ); + var deleted = 0; + for (final item in catalog.files) { + if (item.isCurrent || + item.modifiedAt == null || + !item.modifiedAt!.isBefore(expireBefore)) { + continue; + } + final state = states[item.relativePath]; + final file = File(item.path); + if (state == null) { + logger.w('|终端日志清理|过期日志尚未上传,暂不删除path:${item.relativePath}|'); + continue; + } + if (await file.exists()) { + final stat = await file.stat(); + // 上传成功后文件可能被继续写入,只有本地快照仍匹配时才允许清理。 + if (state.sourceSize != stat.size || + state.sourceModifiedTime != stat.modified) { + logger.w('|终端日志清理|过期日志上传后已有变化,暂不删除path:${item.relativePath}|'); + continue; + } + await file.delete(); + deleted++; + } + } + return deleted; + } +} diff --git a/lib/common/values/constants.dart b/lib/common/values/constants.dart index 9d9e8cc..357929f 100644 --- a/lib/common/values/constants.dart +++ b/lib/common/values/constants.dart @@ -1,4 +1,6 @@ /// 常量 +import 'package:logger/logger.dart'; + enum LoginVersion { // 不需要加密 v1, @@ -7,6 +9,8 @@ enum LoginVersion { } class Constants { + /// 生产模式写入文件的最低日志等级,达到该等级及以上的日志才会持久化。 + static const productionFileLogLevel = Level.warning; // oauth2 APP模式客户端信息 static const oauth2AppClient = 'android:android'; @@ -17,9 +21,9 @@ class Constants { static const pwdEncKey = 'pigxpigxpigxpigx'; /// 服务 url 线上 - static const apiUrl = 'https://www.pjzhhq.cn/api'; + // static const apiUrl = 'https://www.pjzhhq.cn/api'; - // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; + static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; /// 静态资源 url static const ossUrl = apiUrl; @@ -30,6 +34,14 @@ class Constants { /// 人脸识别 插件回调管道 static const facePlugsEventChannel = 'local/face_discern_event'; + /// 人脸库注册进度管道 + static const facePlugsRegistrationEventChannel = + 'local/face_discern_registration_event'; + + /// 在库照片批量质量检测进度事件通道。 + static const facePlugsQualityAuditEventChannel = + 'local/face_discern_quality_audit_event'; + /// 数据库名字 static const databaseName = 'cash_register.db'; diff --git a/lib/common/widgets/app/app_dialog.dart b/lib/common/widgets/app/app_dialog.dart new file mode 100644 index 0000000..77da24f --- /dev/null +++ b/lib/common/widgets/app/app_dialog.dart @@ -0,0 +1,643 @@ +import 'dart:async'; + +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 全局业务弹框的默认遮罩透明度,用于隔离背景但保留终端上下文。 +const double appDialogDefaultScrimOpacity = 0.45; + +/// 全局业务弹框的默认进场时长,兼顾终端反馈速度与空间连续性。 +const Duration appDialogDefaultTransitionDuration = Duration(milliseconds: 180); + +/// 禁止关闭反馈的最短触发间隔,避免连续点击导致设备持续震动。 +const Duration appDialogBlockedFeedbackThrottle = Duration(milliseconds: 700); + +/// 默认弹框与系统顶部安全区之间的间隔,避免表单弹框紧贴状态栏。 +const double appDialogDefaultTopSpacing = 24; + +/// 弹框与左右、底部可用区域边缘的最小间隔,用于触控和拖动边界保护。 +const double appDialogDefaultEdgeSpacing = 8; + +/// 展示使用统一遮罩、返回拦截、关闭反馈和动效的业务弹框。 +/// +/// [builder] 应返回实际弹框表面,推荐使用 [AppDialog]。路由始终自行管理 +/// ModalBarrier,因此禁止关闭时也能给出视觉和触觉反馈。默认位置位于顶部 +/// 安全区下方,输入法出现后会自动缩入键盘上方的可用区域。 +Future showAppDialog({ + required BuildContext context, + required WidgetBuilder builder, + bool barrierDismissible = true, + bool canPop = true, + bool blockedDismissFeedback = true, + bool useRootNavigator = true, + String barrierLabel = '关闭弹框', + Color? barrierColor, + Alignment alignment = Alignment.topCenter, + Duration transitionDuration = appDialogDefaultTransitionDuration, + RouteSettings? routeSettings, +}) { + return showGeneralDialog( + context: context, + useRootNavigator: useRootNavigator, + barrierDismissible: false, + barrierLabel: barrierLabel, + barrierColor: Colors.transparent, + transitionDuration: transitionDuration, + routeSettings: routeSettings, + pageBuilder: (dialogContext, _, __) => _AppDialogRouteHost( + barrierDismissible: barrierDismissible, + canPop: canPop, + blockedDismissFeedback: blockedDismissFeedback, + barrierLabel: barrierLabel, + barrierColor: barrierColor ?? + Colors.black.withValues(alpha: appDialogDefaultScrimOpacity), + alignment: alignment, + child: builder(dialogContext), + ), + transitionBuilder: (dialogContext, animation, _, child) { + if (MediaQuery.disableAnimationsOf(dialogContext)) return child; + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + return FadeTransition( + opacity: curved, + child: ScaleTransition( + scale: Tween(begin: 0.985, end: 1).animate(curved), + child: child, + ), + ); + }, + ); +} + +/// 全局弹框的 ForUI 风格表面,统一标题栏、内容区、操作区和拖动行为。 +class AppDialog extends StatefulWidget { + /// 创建一个可配置尺寸、关闭入口和拖动能力的业务弹框表面。 + const AppDialog({ + super.key, + required this.title, + required this.content, + this.subtitle, + this.leading, + this.actions = const [], + this.width, + this.height, + this.minWidth = 280, + this.maxWidth = 720, + this.contentPadding, + this.draggable = false, + this.showCloseButton = true, + this.closeButtonKey, + this.onClose, + this.headerTrailing, + this.semanticLabel, + this.clipBehavior = Clip.antiAlias, + }); + + /// 标题栏主标题,承担弹框的首要语义。 + final Widget title; + + /// 标题下方的辅助说明;为空时标题栏采用单行居中布局。 + final Widget? subtitle; + + /// 标题左侧图标或状态标识;为空时不预留位置。 + final Widget? leading; + + /// 弹框的核心业务内容,由调用方管理其业务状态和生命周期。 + final Widget content; + + /// 底部操作按钮;为空时不渲染操作区和顶部分隔线。 + final List actions; + + /// 弹框期望宽度,单位为逻辑像素;为空时由内容和最大宽度约束决定。 + final double? width; + + /// 弹框固定高度,单位为逻辑像素;为空时随内容高度收缩。 + final double? height; + + /// 内容较少时允许的最小宽度,单位为逻辑像素。 + final double minWidth; + + /// 当前视口允许的最大宽度,单位为逻辑像素。 + final double maxWidth; + + /// 内容区内边距;为空时使用终端 8dp 节奏的默认间距。 + final EdgeInsetsGeometry? contentPadding; + + /// 是否允许从标题栏拖动;内容区手势不会触发拖动。 + final bool draggable; + + /// 是否展示标题栏关闭按钮;业务进行中仍可由 [onClose] 决定具体行为。 + final bool showCloseButton; + + /// 标题栏关闭按钮的可选稳定键,供大型业务弹框和 Widget 测试定位。 + final Key? closeButtonKey; + + /// 点击标题栏关闭按钮时的业务回调;为空时关闭按钮自动不可用。 + final VoidCallback? onClose; + + /// 标题栏右侧、关闭按钮之前的附加状态或操作。 + final Widget? headerTrailing; + + /// 无障碍服务播报的弹框名称;为空时由标题文本自身提供语义。 + final String? semanticLabel; + + /// 弹框内容超出圆角表面时的裁剪策略。 + final Clip clipBehavior; + + /// 创建弹框的拖动、反馈高亮和表面布局状态。 + @override + State createState() => _AppDialogState(); +} + +/// [AppDialog] 的拖动位置和禁止关闭反馈状态。 +class _AppDialogState extends State { + /// 实际弹框表面的布局键,用于计算可拖动边界。 + final GlobalKey _surfaceKey = GlobalKey(); + + /// 相对初始对齐位置的拖动偏移。 + Offset _dragOffset = Offset.zero; + + /// 上一次构建使用的键盘遮挡高度,用于在输入法变化时清理失效拖动位置。 + double _lastKeyboardInset = 0; + + /// 当前拖动开始时指针的全局位置;未拖动时为空。 + Offset? _dragStartGlobalPosition; + + /// 当前拖动开始时已经累计的表面偏移,用于按一次手势的累计位移计算目标位置。 + Offset? _dragStartOffset; + + /// 当前拖动开始时表面的全局矩形,边界计算期间保持不变以避免边缘来回修正。 + Rect? _dragStartSurfaceRect; + + /// 减少动态效果时显示的短暂边框高亮状态。 + bool _blockedHighlight = false; + + /// 当前路由宿主提供的禁止关闭反馈信号。 + ValueNotifier? _blockedFeedbackSignal; + + /// 清理短暂高亮的延迟任务;释放弹框时必须取消。 + Timer? _highlightTimer; + + /// 绑定当前路由的禁止关闭反馈信号,支持弹框跨主题重建。 + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final keyboardInset = MediaQuery.viewInsetsOf(context).bottom; + if (keyboardInset != _lastKeyboardInset) { + _lastKeyboardInset = keyboardInset; + // 键盘会改变可用区域,旧拖动偏移不再具有合法的屏幕坐标语义。 + _dragOffset = Offset.zero; + _handleDragEnd(); + } + final signal = _AppDialogBlockedFeedbackScope.maybeOf(context); + if (identical(signal, _blockedFeedbackSignal)) return; + _blockedFeedbackSignal?.removeListener(_handleBlockedFeedback); + _blockedFeedbackSignal = signal; + _blockedFeedbackSignal?.addListener(_handleBlockedFeedback); + } + + /// 取消信号监听和高亮任务,避免已关闭弹框继续刷新状态。 + @override + void dispose() { + _highlightTimer?.cancel(); + _blockedFeedbackSignal?.removeListener(_handleBlockedFeedback); + super.dispose(); + } + + /// 在减少动态效果模式下用边框高亮替代位置抖动。 + void _handleBlockedFeedback() { + if (!mounted || !MediaQuery.disableAnimationsOf(context)) return; + _highlightTimer?.cancel(); + setState(() => _blockedHighlight = true); + _highlightTimer = Timer(const Duration(milliseconds: 240), () { + if (mounted) setState(() => _blockedHighlight = false); + }); + } + + /// 记录本次标题栏拖动的稳定基准,避免使用已变换矩形逐帧累加产生边缘抖动。 + void _handleDragStart(DragStartDetails details) { + if (!widget.draggable) return; + final renderBox = + _surfaceKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null || !renderBox.hasSize) return; + _dragStartGlobalPosition = details.globalPosition; + _dragStartOffset = _dragOffset; + _dragStartSurfaceRect = + renderBox.localToGlobal(Offset.zero) & renderBox.size; + } + + /// 根据当前手势累计位移更新位置,并限制整个弹框始终位于系统安全区域内。 + void _handleDragUpdate(DragUpdateDetails details) { + if (!widget.draggable) return; + final dragStartGlobalPosition = _dragStartGlobalPosition; + final dragStartOffset = _dragStartOffset; + final dragStartSurfaceRect = _dragStartSurfaceRect; + if (dragStartGlobalPosition == null || + dragStartOffset == null || + dragStartSurfaceRect == null) { + return; + } + final viewport = MediaQuery.sizeOf(context); + // SafeArea 会把后代的 padding 清零,viewPadding 才能稳定保留系统栏原始占用。 + final padding = MediaQuery.viewPaddingOf(context); + final keyboardInset = MediaQuery.viewInsetsOf(context).bottom; + final bottomObstruction = + keyboardInset > padding.bottom ? keyboardInset : padding.bottom; + final safeRect = Rect.fromLTRB( + padding.left + appDialogDefaultEdgeSpacing, + padding.top + appDialogDefaultEdgeSpacing, + viewport.width - padding.right - appDialogDefaultEdgeSpacing, + viewport.height - bottomObstruction - appDialogDefaultEdgeSpacing, + ); + final requestedDelta = details.globalPosition - dragStartGlobalPosition; + final clampedDelta = Offset( + _clampDragAxis( + requestedDelta.dx, + safeRect.left - dragStartSurfaceRect.left, + safeRect.right - dragStartSurfaceRect.right, + ), + _clampDragAxis( + requestedDelta.dy, + safeRect.top - dragStartSurfaceRect.top, + safeRect.bottom - dragStartSurfaceRect.bottom, + ), + ); + final nextOffset = dragStartOffset + clampedDelta; + if (nextOffset == _dragOffset) return; + setState(() => _dragOffset = nextOffset); + } + + /// 在拖动结束或取消时释放手势基准,下一次拖动会从当前表面位置重新计算。 + void _handleDragEnd() { + _dragStartGlobalPosition = null; + _dragStartOffset = null; + _dragStartSurfaceRect = null; + } + + /// 将单轴位移限制到合法区间;极小窗口放不下弹框时使用区间中点保持位置稳定。 + double _clampDragAxis(double value, double minimum, double maximum) { + if (minimum > maximum) return (minimum + maximum) / 2; + return value.clamp(minimum, maximum).toDouble(); + } + + /// 构建使用现有 ForUI 主题指标的标题、内容和操作区。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final requestedWidth = widget.width ?? widget.maxWidth; + final viewport = MediaQuery.sizeOf(context); + final safePadding = MediaQuery.viewPaddingOf(context); + final safeMaxWidth = (viewport.width - safePadding.horizontal - 16) + .clamp(0, double.infinity); + final widthUpperBound = safeMaxWidth < widget.maxWidth + ? safeMaxWidth.toDouble() + : widget.maxWidth; + final widthLowerBound = widget.minWidth.clamp(0, widthUpperBound); + final actualWidth = requestedWidth.clamp( + widthLowerBound, + widthUpperBound, + ); + final safeMaxHeight = + (viewport.height - safePadding.vertical - 16).clamp(0, double.infinity); + final actualHeight = widget.height?.clamp(0, safeMaxHeight).toDouble(); + final surface = Container( + key: _surfaceKey, + width: actualWidth.toDouble(), + height: actualHeight, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + boxShadow: XfjDesignTokens.surfaceShadow, + ), + child: Material( + color: XfjDesignTokens.surface, + shape: RoundedRectangleBorder( + side: BorderSide( + color: _blockedHighlight + ? XfjDesignTokens.warning + : XfjDesignTokens.line, + width: _blockedHighlight ? 2 : 1, + ), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + ), + clipBehavior: widget.clipBehavior, + child: [ + _buildHeader(theme), + const Divider(height: 1, color: XfjDesignTokens.line), + widget.height == null + ? Padding( + padding: widget.contentPadding ?? EdgeInsets.all(20.w), + child: widget.content, + ) + : Expanded( + child: Padding( + padding: widget.contentPadding ?? EdgeInsets.all(20.w), + child: widget.content, + ), + ), + if (widget.actions.isNotEmpty) ...[ + const Divider(height: 1, color: XfjDesignTokens.line), + _buildActions(), + ], + ].toColumn(mainAxisSize: MainAxisSize.min), + ), + ); + return Semantics( + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: widget.semanticLabel, + child: Transform.translate(offset: _dragOffset, child: surface), + ); + } + + /// 构建仅标题栏可拖动的区域,并保留 48dp 关闭触控目标。 + Widget _buildHeader(FThemeData theme) { + final titleBlock = [ + DefaultTextStyle( + style: theme.typography.lg.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + child: widget.title, + ), + if (widget.subtitle != null) + DefaultTextStyle( + style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + child: widget.subtitle!, + ), + ].toColumn( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + ); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onPanStart: widget.draggable ? _handleDragStart : null, + onPanUpdate: widget.draggable ? _handleDragUpdate : null, + onPanEnd: widget.draggable ? (_) => _handleDragEnd() : null, + onPanCancel: widget.draggable ? _handleDragEnd : null, + child: [ + if (widget.leading != null) widget.leading!, + if (widget.leading != null) SizedBox(width: 12.w), + titleBlock.expanded(), + if (widget.draggable) ...[ + FIcon( + FAssets.icons.gripHorizontal, + size: 20.w, + color: XfjDesignTokens.muted, + ), + SizedBox(width: 8.w), + ], + if (widget.headerTrailing != null) ...[ + widget.headerTrailing!, + SizedBox(width: 8.w), + ], + if (widget.showCloseButton) + Semantics( + button: true, + enabled: widget.onClose != null, + label: '关闭弹框', + child: InkWell( + key: widget.closeButtonKey, + onTap: widget.onClose, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: FIcon( + FAssets.icons.x, + size: 22.w, + color: widget.onClose == null + ? XfjDesignTokens.line + : XfjDesignTokens.muted, + ).center().width(48.w).height(48.h), + ), + ), + ].toRow(crossAxisAlignment: CrossAxisAlignment.center).padding( + horizontal: 20.w, + vertical: 10.h, + ), + ); + } + + /// 构建统一右对齐的底部操作区,按钮本身由业务调用方决定层级。 + Widget _buildActions() { + return Wrap( + alignment: WrapAlignment.end, + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 12.w, + runSpacing: 8.h, + // ForUI 按钮内部使用默认占满主轴的 Row,直接放入 Wrap 会让每个按钮 + // 各自占满一行;先按固有宽度测量,才能恢复桌面端横向操作区语义。 + children: widget.actions + .map((action) => IntrinsicWidth(child: action)) + .toList(growable: false), + ).padding(horizontal: 20.w, vertical: 14.h); + } +} + +/// 全局弹框路由宿主,负责区分遮罩点击、系统返回和实际内容点击。 +class _AppDialogRouteHost extends StatefulWidget { + /// 创建一层只在业务允许时关闭的弹框路由。 + const _AppDialogRouteHost({ + required this.barrierDismissible, + required this.canPop, + required this.blockedDismissFeedback, + required this.barrierLabel, + required this.barrierColor, + required this.alignment, + required this.child, + }); + + /// 点击遮罩是否允许关闭当前弹框。 + final bool barrierDismissible; + + /// 系统返回是否允许关闭当前弹框。 + final bool canPop; + + /// 阻止关闭时是否启用视觉和触觉反馈。 + final bool blockedDismissFeedback; + + /// 遮罩关闭区域的无障碍名称。 + final String barrierLabel; + + /// 弹框背景遮罩颜色。 + final Color barrierColor; + + /// 弹框表面在安全区域内的初始对齐位置。 + final Alignment alignment; + + /// 调用方构建的实际弹框表面。 + final Widget child; + + /// 创建路由的抖动动画、反馈节流和关闭控制状态。 + @override + State<_AppDialogRouteHost> createState() => _AppDialogRouteHostState(); +} + +/// [_AppDialogRouteHost] 的关闭反馈状态。 +class _AppDialogRouteHostState extends State<_AppDialogRouteHost> + with SingleTickerProviderStateMixin { + /// 非减少动态效果模式下使用的短促抖动控制器。 + late final AnimationController _shakeController; + + /// 将抖动进度转换为水平方向位移的动画。 + late final Animation _shakeOffset; + + /// 通知 [AppDialog] 在减少动态效果时显示边框高亮。 + final ValueNotifier _blockedFeedbackSignal = ValueNotifier(0); + + /// 上一次阻止关闭反馈时间,用于限制震动频率。 + DateTime? _lastBlockedFeedbackAt; + + /// 初始化 180ms 抖动序列,位移仅作用于合成层,不触发布局重排。 + @override + void initState() { + super.initState(); + _shakeController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 180), + ); + _shakeOffset = TweenSequence([ + TweenSequenceItem(tween: Tween(begin: 0, end: -8), weight: 20), + TweenSequenceItem(tween: Tween(begin: -8, end: 8), weight: 30), + TweenSequenceItem(tween: Tween(begin: 8, end: -5), weight: 25), + TweenSequenceItem(tween: Tween(begin: -5, end: 0), weight: 25), + ]).animate(CurvedAnimation( + parent: _shakeController, + curve: Curves.easeOutCubic, + )); + } + + /// 释放动画控制器和反馈信号,避免弹框退出后继续持有监听器。 + @override + void dispose() { + _shakeController.dispose(); + _blockedFeedbackSignal.dispose(); + super.dispose(); + } + + /// 点击遮罩时按配置关闭,或向用户说明当前操作不可中断。 + void _handleBarrierTap() { + if (widget.barrierDismissible) { + Navigator.of(context).pop(); + return; + } + _triggerBlockedFeedback(); + } + + /// 处理被 [PopScope] 阻止的系统返回操作。 + void _handlePopInvoked(bool didPop, T? result) { + if (!didPop && !widget.canPop) _triggerBlockedFeedback(); + } + + /// 节流触发短抖动、减少动态高亮和设备触觉反馈。 + void _triggerBlockedFeedback() { + if (!widget.blockedDismissFeedback) return; + final now = DateTime.now(); + if (_lastBlockedFeedbackAt != null && + now.difference(_lastBlockedFeedbackAt!) < + appDialogBlockedFeedbackThrottle) { + return; + } + _lastBlockedFeedbackAt = now; + _blockedFeedbackSignal.value++; + if (!MediaQuery.disableAnimationsOf(context)) { + _shakeController.forward(from: 0); + } + unawaited(AppRuntimeTool.vibrate(durationMs: 60)); + } + + /// 构建可命中遮罩、但不抢占弹框内部手势的安全区域布局。 + @override + Widget build(BuildContext context) { + final safePadding = MediaQuery.viewPaddingOf(context); + final keyboardInset = MediaQuery.viewInsetsOf(context).bottom; + final bottomObstruction = + keyboardInset > safePadding.bottom ? keyboardInset : safePadding.bottom; + final topSpacing = widget.alignment.y <= Alignment.topCenter.y + ? appDialogDefaultTopSpacing + : appDialogDefaultEdgeSpacing; + final dialogInsets = EdgeInsets.fromLTRB( + safePadding.left + appDialogDefaultEdgeSpacing, + safePadding.top + topSpacing, + safePadding.right + appDialogDefaultEdgeSpacing, + bottomObstruction + appDialogDefaultEdgeSpacing, + ); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + return PopScope( + canPop: widget.canPop, + onPopInvokedWithResult: _handlePopInvoked, + child: ColoredBox( + color: widget.barrierColor, + child: Stack( + fit: StackFit.expand, + children: [ + Semantics( + button: true, + enabled: widget.barrierDismissible, + label: widget.barrierLabel, + onTap: _handleBarrierTap, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _handleBarrierTap, + child: const SizedBox.expand(), + ), + ), + AnimatedPadding( + // 不使用 SafeArea 改写后代 MediaQuery;键盘出现时直接缩小弹框路由的 + // 可用区域,使输入框和底部操作区始终位于键盘上方。 + padding: dialogInsets, + duration: reduceMotion + ? Duration.zero + : appDialogDefaultTransitionDuration, + curve: Curves.easeOutCubic, + child: Align( + alignment: widget.alignment, + child: AnimatedBuilder( + animation: _shakeOffset, + builder: (_, child) => Transform.translate( + offset: Offset(_shakeOffset.value, 0), + child: child, + ), + child: _AppDialogBlockedFeedbackScope( + signal: _blockedFeedbackSignal, + child: widget.child, + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// 向弹框表面传递禁止关闭反馈信号的内部作用域。 +class _AppDialogBlockedFeedbackScope extends InheritedWidget { + /// 创建绑定到当前弹框路由的反馈作用域。 + const _AppDialogBlockedFeedbackScope({ + required this.signal, + required super.child, + }); + + /// 每次有效阻止关闭时递增的通知信号。 + final ValueNotifier signal; + + /// 获取离当前弹框表面最近的反馈信号;非全局路由环境下返回空。 + static ValueNotifier? maybeOf(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType<_AppDialogBlockedFeedbackScope>() + ?.signal; + } + + /// 信号实例在路由生命周期内保持不变,不需要重建依赖子树。 + @override + bool updateShouldNotify(_AppDialogBlockedFeedbackScope oldWidget) => + !identical(signal, oldWidget.signal); +} diff --git a/lib/common/widgets/app/app_page.dart b/lib/common/widgets/app/app_page.dart index 4d248e6..31ce571 100644 --- a/lib/common/widgets/app/app_page.dart +++ b/lib/common/widgets/app/app_page.dart @@ -10,13 +10,14 @@ Widget appPage( required int pageSize, required int total, required Function(int) onPageChanged, + String itemLabel = '条数据', }) { final theme = FTheme.of(context); final totalPages = (total / pageSize).ceil(); return [ [ Text( - "每页$pageSize条,共$total条", + "每页$pageSize条,共$total条$itemLabel", style: theme.typography.lg.copyWith( fontWeight: theme.typography.xl8.fontWeight, color: theme.colorScheme.primary, diff --git a/lib/common/widgets/app/app_radio_hour_type_dialog.dart b/lib/common/widgets/app/app_radio_hour_type_dialog.dart index 3dfefa0..9f38da2 100644 --- a/lib/common/widgets/app/app_radio_hour_type_dialog.dart +++ b/lib/common/widgets/app/app_radio_hour_type_dialog.dart @@ -1,30 +1,107 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/material.dart'; import 'package:forui/forui.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; +/// 构建全终端共用的餐段选择组件,选中值由调用方持有的 Forui 单选 Controller 管理。 +/// +/// [title] 和 [enabled] 允许业务页面调整触发器文案及交互状态;[key] 用于定位具体业务入口。 +/// [compact] 仅降低触发器的视觉权重,不缩小餐段选项的触控区域;查询菜单使用该模式, +/// 收银、预订和设置页面继续使用原有的图标与强调色样式。 +/// [checkConfiguredTime] 开启后会在页面初始化完成时校验当前餐段;[ready] 用于等待调用方恢复自身状态。 +/// [currentHourTypeLoader] 允许调用方提供当前配置餐段读取器,未提供时读取本机餐段配置。 +/// [allowManualChange] 关闭后禁止人工切换餐段:选择器只读,且与餐段配置不符时静默切换为配置餐段。 Widget appRadioHourTypeDialog( BuildContext context, - FRadioSelectGroupController controller, -) { + FRadioSelectGroupController controller, { + Key? key, + String title = "用餐时间", + bool enabled = true, + bool compact = false, + bool checkConfiguredTime = false, + bool allowManualChange = true, + Future? ready, + Future Function()? currentHourTypeLoader, +}) { + if (checkConfiguredTime) { + return _MealPeriodSelectionGuard( + controller: controller, + ready: ready, + allowManualChange: allowManualChange, + currentHourTypeLoader: currentHourTypeLoader, + selectorBuilder: (context, candidateController) => _buildHourTypeSelector( + context, + candidateController, + key: key, + title: title, + enabled: enabled && allowManualChange, + compact: compact, + ), + ); + } + return _buildHourTypeSelector( + context, + controller, + key: key, + title: title, + enabled: enabled && allowManualChange, + compact: compact, + ); +} + +/// 使用指定单选 Controller 构建餐段菜单;时间校验模式会传入尚未提交的候选 Controller。 +Widget _buildHourTypeSelector( + BuildContext context, + FRadioSelectGroupController controller, { + Key? key, + required String title, + required bool enabled, + required bool compact, +}) { final theme = FTheme.of(context); final list = ProductHourType.values.where((v) => v.code != "-1"); + final compactTriggerStyle = _withToolbarHeight( + context, + theme.selectMenuTileStyle.tileStyle, + ); + final compactOptionStyle = _withMinimumTouchPadding( + theme.selectMenuTileStyle.menuStyle.tileGroupStyle.tileStyle, + ); + // Forui 0.9.1 的 Tile 会根据字体和内边距计算高度,不能套固定高度约束。 + // 否则字号缩放或路由切换时 RenderTile 的实际高度与父约束不一致,会连续触发布局异常。 return FSelectMenuTile( + key: key, + style: compact + ? theme.selectMenuTileStyle.copyWith(tileStyle: compactTriggerStyle) + : null, title: Text( - "用餐时间", - style: theme.typography.base, + title, + style: (compact ? theme.typography.sm : theme.typography.base).copyWith( + color: compact ? XfjDesignTokens.muted : null, + fontWeight: compact ? FontWeight.w600 : null, + ), ), groupController: controller, + enabled: enabled, validator: (value) => value == null ? '请选择' : null, autoHide: true, - prefixIcon: FIcon( - FAssets.icons.sun, - size: theme.typography.xl.fontSize, - ), + semanticLabel: '$title选择', + prefixIcon: compact + ? null + : FIcon( + FAssets.icons.sun, + size: theme.typography.xl.fontSize, + ), menu: list.map((v) { return FSelectTile( + key: ValueKey('meal-slot-option-${v.code}'), + style: compact ? compactOptionStyle : null, title: Text( v.title, - style: theme.typography.xl, + style: compact ? theme.typography.base : theme.typography.xl, ), value: v.code, ); @@ -38,10 +115,305 @@ Widget appRadioHourTypeDialog( return Text( hourType.title, style: theme.typography.base.copyWith( - color: theme.colorScheme.primary, + color: !enabled + ? XfjDesignTokens.muted + : compact + ? XfjDesignTokens.ink + : theme.colorScheme.primary, + fontWeight: compact ? FontWeight.w700 : null, ), ); }, ), ); } + +/// 在不固定 Tile 高度的前提下补足紧凑模式内边距,使其触控区域至少达到 48dp。 +FTileStyle _withMinimumTouchPadding(FTileStyle style) { + final contentStyle = style.contentStyle; + final padding = contentStyle.padding; + return style.copyWith( + contentStyle: contentStyle.copyWith( + padding: padding.copyWith( + top: padding.top + 2, + bottom: padding.bottom + 2, + ), + ), + ); +} + +/// 让紧凑餐段入口与首页工具栏的输入框、“搜索”“重置”按钮保持同一高度。 +/// +/// 首页工具栏控件的高度都直接取 [XfjDesignTokens.controlHeight],因此这里使用 +/// 完全相同的表达式;不得再叠加 48px 触控下限,否则在竖向分辨率缩放后 +/// (如 1280×800)或紧凑按钮档位下会比同排控件高出数像素。 +FTileStyle _withToolbarHeight(BuildContext context, FTileStyle style) { + final contentStyle = style.contentStyle; + final textPainter = TextPainter( + text: TextSpan( + text: '中餐', + style: contentStyle.enabledStyle.detailsTextStyle, + ), + maxLines: 1, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + textHeightBehavior: const TextHeightBehavior( + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, + ), + )..layout(); + final iconHeight = contentStyle.enabledStyle.suffixIconStyle.size; + final contentHeight = math.max(textPainter.height, iconHeight).toDouble(); + final targetHeight = XfjDesignTokens.controlHeight.h; + final verticalPadding = math + .max( + 0, + (targetHeight - contentHeight) / 2, + ) + .toDouble(); + textPainter.dispose(); + return style.copyWith( + contentStyle: contentStyle.copyWith( + padding: contentStyle.padding.copyWith( + top: verticalPadding, + bottom: verticalPadding, + ), + ), + ); +} + +/// 根据内部候选 Controller 构建餐段菜单,避免用户确认前改动应用级餐段。 +typedef _MealPeriodSelectorBuilder = Widget Function( + BuildContext context, + FRadioSelectGroupController candidateController, +); + +/// 先校验候选餐段再提交到应用级 Controller,页面间共用同一套确认规则。 +class _MealPeriodSelectionGuard extends StatefulWidget { + /// 创建餐段时间校验包装器;[ready] 完成前不会读取当前时间配置。 + const _MealPeriodSelectionGuard({ + required this.controller, + required this.selectorBuilder, + this.ready, + this.allowManualChange = true, + this.currentHourTypeLoader, + }); + + /// 已提交的应用级餐段 Controller;只有校验通过或用户确认后才会被修改。 + final FRadioSelectGroupController controller; + + /// 页面业务初始化信号;为空表示无需等待额外初始化。 + final Future? ready; + + /// 是否允许人工切换餐段;关闭后忽略人工候选并静默跟随餐段配置。 + final bool allowManualChange; + + /// 当前时间命中餐段的读取器;测试可注入固定结果。 + final Future Function()? currentHourTypeLoader; + + /// 使用内部候选 Controller 构建餐段菜单的回调。 + final _MealPeriodSelectorBuilder selectorBuilder; + + /// 创建负责异步校验与监听清理的状态对象。 + @override + State<_MealPeriodSelectionGuard> createState() => + _MealPeriodSelectionGuardState(); +} + +/// 管理候选餐段、配置校验、弹框确认和最终全局提交。 +class _MealPeriodSelectionGuardState extends State<_MealPeriodSelectionGuard> { + /// 菜单内部使用的候选选择器;用户点击不会直接触发页面刷新或缓存写入。 + late FRadioSelectGroupController _candidateController; + + /// 当前是否已有校验在执行,避免一次选择触发多个重叠弹框。 + bool _checking = false; + + /// 是否已经登记帧后校验任务,合并同一帧内的多次选择通知。 + bool _checkScheduled = false; + + /// 选择修订号;异步读取或弹框返回时用它丢弃过期结果。 + int _selectionRevision = 0; + + /// 最近一次尚未校验的人工候选餐段;新选择会覆盖尚未开始处理的旧选择。 + ProductHourType? _pendingCandidate; + + /// 内部回退候选显示时屏蔽候选监听,防止产生新的人工选择请求。 + bool _syncingCandidate = false; + + /// 校验通过后写入全局 Controller 时屏蔽外部变化复检,避免重复弹框。 + bool _applyingApprovedSelection = false; + + /// 创建候选 Controller、注册全局监听,并在首帧后校验缓存恢复出的初始餐段。 + @override + void initState() { + super.initState(); + _createCandidateController(); + widget.controller.addValueListener(_handleCommittedSelectionChanged); + _scheduleCheck(); + } + + /// Controller 或初始化 Future 变化时重建监听并重新校验当前选择。 + @override + void didUpdateWidget(covariant _MealPeriodSelectionGuard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller + .removeValueListener(_handleCommittedSelectionChanged); + _disposeCandidateController(); + _createCandidateController(); + widget.controller.addValueListener(_handleCommittedSelectionChanged); + _pendingCandidate = null; + _selectionRevision++; + _scheduleCheck(); + } else if (oldWidget.ready != widget.ready || + oldWidget.currentHourTypeLoader != widget.currentHourTypeLoader || + oldWidget.allowManualChange != widget.allowManualChange) { + _scheduleCheck(); + } + } + + /// 页面卸载时释放候选选择器并移除全局监听,防止隐藏页面继续弹出提示。 + @override + void dispose() { + widget.controller.removeValueListener(_handleCommittedSelectionChanged); + _disposeCandidateController(); + super.dispose(); + } + + /// 用当前已提交餐段创建内部候选 Controller,并注册人工选择监听。 + void _createCandidateController() { + _candidateController = FRadioSelectGroupController( + value: widget.controller.value.firstOrNull, + )..addValueListener(_handleCandidateSelectionChanged); + } + + /// 移除候选监听并释放 Forui Controller 资源。 + void _disposeCandidateController() { + _candidateController.removeValueListener(_handleCandidateSelectionChanged); + _candidateController.dispose(); + } + + /// 接收人工候选餐段,立即恢复已提交值的显示,再异步校验是否允许提交。 + void _handleCandidateSelectionChanged(Set values) { + if (_syncingCandidate) return; + final candidate = ProductHourType.values.fromCode(values.firstOrNull); + final committedCode = widget.controller.value.firstOrNull; + // 禁止人工修改时任何候选都不受理,候选显示立即回退为已提交餐段。 + if (!widget.allowManualChange || + candidate == null || + candidate == ProductHourType.TOTAL || + candidate.code == committedCode) { + _syncCandidateFromCommitted(); + return; + } + _pendingCandidate = candidate; + _selectionRevision++; + _syncCandidateFromCommitted(); + _scheduleCheck(); + } + + /// 接收其他页面或业务代码提交的全局餐段,更新候选显示并重新检查时间配置。 + void _handleCommittedSelectionChanged(Set _) { + _syncCandidateFromCommitted(); + if (_applyingApprovedSelection) return; + _pendingCandidate = null; + _selectionRevision++; + _scheduleCheck(); + } + + /// 把候选显示恢复为当前全局值,确保弹框确认前页面仍展示并使用原餐段。 + void _syncCandidateFromCommitted() { + final committedCode = widget.controller.value.firstOrNull; + if (_candidateController.value.firstOrNull == committedCode) return; + _syncingCandidate = true; + try { + _candidateController.value = committedCode == null ? {} : {committedCode}; + } finally { + _syncingCandidate = false; + } + } + + /// 合并重复请求后登记一次帧后校验,避免在组件构建阶段打开弹框。 + void _scheduleCheck() { + if (_checkScheduled || !mounted) return; + _checkScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkScheduled = false; + unawaited(_checkSelection()); + }); + } + + /// 校验人工候选或当前缓存餐段;人工确认后提交候选,初始化确认后提交配置餐段。 + /// + /// 禁止人工修改时不弹确认框:与餐段配置不符直接静默切换为配置餐段, + /// 当前时间未命中任何餐段时保留原选择,避免把餐段清空成不可用状态。 + Future _checkSelection() async { + if (_checking || !mounted) return; + _checking = true; + final revision = _selectionRevision; + final candidate = _pendingCandidate; + _pendingCandidate = null; + try { + await widget.ready; + if (!mounted) return; + final selected = candidate ?? + ProductHourType.values.fromCode( + widget.controller.value.firstOrNull, + ); + if (selected == null || selected == ProductHourType.TOTAL) return; + final configured = await (widget.currentHourTypeLoader?.call() ?? + AppSettingTool.getCurrentProductHourType()); + if (!mounted || revision != _selectionRevision) return; + if (!widget.allowManualChange) { + if (configured != null && configured != selected) { + _commitSelection(configured); + } + return; + } + if (candidate != null && + (configured == null || configured == candidate)) { + _commitSelection(candidate); + return; + } + if (configured == null || configured == selected) return; + final message = candidate == null + ? '当前选择的餐段可能有问题,按餐段配置当前时间应为“${configured.title}”,是否切换?' + : '当前选择的餐段可能有问题,按餐段配置当前时间应为“${configured.title}”,是否仍使用“${candidate.title}”?'; + final confirmed = await appMessageDialog(context, message); + if (!mounted || revision != _selectionRevision) return; + if (candidate != null) { + if (confirmed) _commitSelection(candidate); + } else if (confirmed) { + _commitSelection(configured); + } + } catch (error, stack) { + logger.w('|餐段配置|校验当前餐段失败error:|$error|stack:|$stack|'); + } finally { + _checking = false; + if (mounted && + (_pendingCandidate != null || revision != _selectionRevision)) { + _scheduleCheck(); + } + } + } + + /// 把已通过校验的餐段提交到全局 Controller,此时页面刷新和缓存写入才会发生。 + void _commitSelection(ProductHourType hourType) { + if (widget.controller.value.firstOrNull == hourType.code) { + _syncCandidateFromCommitted(); + return; + } + _applyingApprovedSelection = true; + try { + widget.controller.value = {hourType.code}; + } finally { + _applyingApprovedSelection = false; + } + _syncCandidateFromCommitted(); + } + + /// 使用候选 Controller 构建菜单,使人工选择在确认前与全局业务状态隔离。 + @override + Widget build(BuildContext context) => + widget.selectorBuilder(context, _candidateController); +} diff --git a/lib/common/widgets/app/terminal_toolbar.dart b/lib/common/widgets/app/terminal_toolbar.dart new file mode 100644 index 0000000..35c77e7 --- /dev/null +++ b/lib/common/widgets/app/terminal_toolbar.dart @@ -0,0 +1,294 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +enum TerminalToolbarButtonStyle { + primary, + secondary, + danger, + destructive, +} + +/// 统一搜索、重置、删除、扫码等工具操作的高度,避免主题尺寸变化后相邻控件错位。 +class TerminalToolbarButton extends StatelessWidget { + const TerminalToolbarButton({ + super.key, + required this.label, + required this.onTap, + this.icon, + this.style = TerminalToolbarButtonStyle.primary, + this.minWidth, + }); + + final String label; + final VoidCallback? onTap; + final SvgAsset? icon; + final TerminalToolbarButtonStyle style; + final double? minWidth; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final enabled = onTap != null; + final background = switch (style) { + TerminalToolbarButtonStyle.primary => XfjDesignTokens.primary, + TerminalToolbarButtonStyle.secondary => XfjDesignTokens.surface, + TerminalToolbarButtonStyle.danger => const Color(0xFFFFF3F2), + TerminalToolbarButtonStyle.destructive => XfjDesignTokens.danger, + }; + final foreground = switch (style) { + TerminalToolbarButtonStyle.primary => Colors.white, + TerminalToolbarButtonStyle.secondary => XfjDesignTokens.ink, + TerminalToolbarButtonStyle.danger => XfjDesignTokens.danger, + TerminalToolbarButtonStyle.destructive => Colors.white, + }; + final border = switch (style) { + TerminalToolbarButtonStyle.primary => XfjDesignTokens.primary, + TerminalToolbarButtonStyle.secondary => XfjDesignTokens.line, + TerminalToolbarButtonStyle.danger => + XfjDesignTokens.danger.withValues(alpha: 0.35), + TerminalToolbarButtonStyle.destructive => XfjDesignTokens.danger, + }; + return Opacity( + opacity: enabled ? 1 : 0.45, + child: Material( + color: background, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Container( + height: XfjDesignTokens.controlHeight.h, + constraints: BoxConstraints(minWidth: minWidth ?? 92.w), + padding: EdgeInsets.symmetric( + horizontal: 18.w * XfjDesignTokens.buttonSizeScale, + ), + decoration: BoxDecoration( + border: Border.all(color: border), + borderRadius: + BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + FIcon(icon!, size: 19.sp, color: foreground), + SizedBox(width: 8.w), + ], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: foreground, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// 终端工具栏统一单行输入框。 +/// +/// Forui 的普通单行输入框会按文字自然高度布局,即使外层工具栏已经等高,边框仍可能 +/// 比按钮更矮;这里统一收敛组件结构和局部主题,让所有工具栏输入框共享同一尺寸规则。 +class TerminalToolbarTextField extends StatelessWidget { + /// 创建由页面持有输入状态、由公共组件负责尺寸和主题适配的工具栏输入框。 + const TerminalToolbarTextField({ + super.key, + required this.controller, + required this.hint, + this.width, + this.focusNode, + this.textInputAction, + this.keyboardType, + this.onSubmit, + this.prefixBuilder, + }); + + /// 输入框宽度;为空时由父级布局分配可用宽度。 + final double? width; + + /// 页面持有的文本控制器,公共组件不接管其生命周期。 + final TextEditingController controller; + + /// 页面持有的焦点节点;为空时沿用 Forui 的内部焦点管理。 + final FocusNode? focusNode; + + /// 无输入内容时展示的业务提示。 + final String hint; + + /// 软键盘动作类型;硬件输入场景可继续传入现有配置。 + final TextInputAction? textInputAction; + + /// 输入键盘类型;收银扫码等场景可传入 [TextInputType.none] 禁止弹出软键盘。 + final TextInputType? keyboardType; + + /// 用户提交输入时的页面回调,组件本身不触发查询副作用。 + final ValueChanged? onSubmit; + + /// 输入框前缀构建器,用于页面已有的搜索图标等语义提示。 + final ValueWidgetBuilder? prefixBuilder; + + /// 构建随按钮大小档位变化的单行输入框。 + @override + Widget build(BuildContext context) { + return TerminalToolbarControl( + width: width, + child: FTextField( + controller: controller, + focusNode: focusNode, + hint: hint, + textInputAction: textInputAction, + keyboardType: keyboardType, + maxLines: 1, + onSubmit: onSubmit, + prefixBuilder: prefixBuilder, + ), + ); + } +} + +/// 给工具栏输入框提供与搜索、重置按钮一致的确定高度。 +/// +/// 高度统一取自 [XfjDesignTokens.controlHeight],因此会随基本设置中的按钮大小档位 +/// 一起变化;局部主题同时收敛 Forui 内边距,避免大字号终端出现内容溢出。 +class TerminalToolbarControl extends StatelessWidget { + /// 创建负责约束工具栏控件尺寸和局部输入主题的容器。 + const TerminalToolbarControl({super.key, required this.child, this.width}); + + /// 需要与工具栏按钮对齐的输入控件。 + final Widget child; + + /// 控件宽度;为空时由父级布局分配可用宽度。 + final double? width; + + /// 根据当前字体与按钮档位计算输入内边距,保证实际边框高度等于按钮高度。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final controlHeight = XfjDesignTokens.controlHeight.h; + final textPainter = TextPainter( + text: TextSpan( + text: '国Hg', + style: theme.textFieldStyle.enabledStyle.contentTextStyle, + ), + maxLines: 1, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + // Forui 单行输入框的绘制高度由文字行高和上下内边距组成,按运行时字体反算 + // 内边距后,字体大小和按钮大小可独立变化且不会再次造成控件错位。 + final verticalPadding = ((controlHeight - textPainter.height) / 2) + .clamp(0.0, controlHeight / 2); + textPainter.dispose(); + return FTheme( + // Forui 0.9.1+1 的默认输入内边距是未适配的逻辑像素,工具栏内需按终端比例收敛。 + data: theme.copyWith( + textFieldStyle: theme.textFieldStyle.copyWith( + contentPadding: EdgeInsets.symmetric( + horizontal: 14.w, + vertical: verticalPadding, + ), + ), + ), + child: SizedBox( + width: width, + height: controlHeight, + child: child, + ), + ); + } +} + +/// 终端工具栏日期范围控件,统一处理紧凑横屏下的完整日期展示。 +class TerminalToolbarDateControl extends StatelessWidget { + const TerminalToolbarDateControl({ + super.key, + required this.label, + required this.onTap, + this.width, + this.showChevron = false, + this.textStyle, + }); + + final String label; + final VoidCallback onTap; + final double? width; + final bool showChevron; + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return SizedBox( + width: width, + child: Semantics( + button: true, + label: '日期范围:$label', + child: Material( + color: XfjDesignTokens.surface, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + side: const BorderSide(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: InkWell( + onTap: onTap, + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: XfjDesignTokens.controlHeight.h, + ), + child: [ + FIcon( + FAssets.icons.calendar, + size: 19.sp, + color: XfjDesignTokens.ink, + ), + SizedBox(width: 8.w), + Expanded( + // 日期文本只在空间不足时轻微缩小,避免省略结束日期或触发横向溢出。 + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + label, + maxLines: 1, + style: textStyle ?? + theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + if (showChevron) ...[ + SizedBox(width: 8.w), + FIcon( + FAssets.icons.chevronsUpDown, + size: 16.sp, + color: XfjDesignTokens.muted, + ), + ], + ] + .toRow(crossAxisAlignment: CrossAxisAlignment.center) + .padding(horizontal: 12.w), + ), + ), + ), + ), + ); + } +} diff --git a/lib/common/widgets/index.dart b/lib/common/widgets/index.dart index 33e97f0..223f913 100644 --- a/lib/common/widgets/index.dart +++ b/lib/common/widgets/index.dart @@ -18,6 +18,8 @@ export './app/app_qr_code_dialog.dart'; export './app/app_radio_hour_type_dialog.dart'; export './app/app_title.dart'; export './app/app_wallet_selector.dart'; +export './app/app_dialog.dart'; +export './app/terminal_toolbar.dart'; export './card/card_common.dart'; diff --git a/lib/main.dart b/lib/main.dart index f8ee21e..20f88e0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -19,12 +19,21 @@ Future checkSubScreen() async { case "/": WidgetsFlutterBinding.ensureInitialized(); DartPluginRegistrant.ensureInitialized(); + await AppRuntimeTool.initializePlatform(); final appTheme = Get.put(AppThemeControl()); + // 全局餐段状态供商品管理和取餐广播任务读取。 + final productHourType = Get.put( + ProductHourTypeControl(), + permanent: true, + ); + // 人脸识别会话服务常驻,供收银识别流程和空闲判定读取。 + Get.put(FaceRecognitionSessionService(), permanent: true); runApp(MainApp()); await Future.microtask(() async { final app = Get.put(AppControl()); await app.initAsync(); await appTheme.initAsync(); + await productHourType.initAsync(); }); break; case "subMain": diff --git a/lib/pages/setting/controller.dart b/lib/pages/setting/controller.dart index 65fd82f..ae9c439 100644 --- a/lib/pages/setting/controller.dart +++ b/lib/pages/setting/controller.dart @@ -46,7 +46,7 @@ class SettingController extends GetxController { ), SettingMenu( prefixIcon: FAssets.icons.refreshCw, - title: "同步设置", + title: "定时任务", type: SettingMenuType.syncSetting, ), SettingMenu( @@ -64,6 +64,11 @@ class SettingController extends GetxController { title: "商品管理", type: SettingMenuType.productSetting, ), + SettingMenu( + prefixIcon: FAssets.icons.settings2, + title: "人脸设置", + type: SettingMenuType.facePluginSetting, + ), SettingMenu( prefixIcon: FAssets.icons.rocket, title: "版本管理", @@ -73,12 +78,22 @@ class SettingController extends GetxController { Rx cuurentMenuType = SettingMenuType.baseInfo.obs; + /// 各任务尚未保存的 Cron 输入草稿,以任务类型隔离编辑状态。 + final scheduledTaskCronDrafts = {}.obs; + + /// 各任务 Cron 草稿的本地校验错误,保存成功或继续输入后移除。 + final scheduledTaskCronErrors = {}.obs; + + /// 正在持久化配置的任务集合,用于禁止同任务重复提交并局部禁用控件。 + final savingScheduledTasks = {}.obs; + late StreamSubscription mqUpdateListen; @override void onInit() async { super.onInit(); await getSetting(); + await _initScheduledTasks(); globalFontSizeC.addValueListener((v) { final fontSizeType = SettingGlobalFontSize.values.fromCode( v.first, @@ -143,7 +158,127 @@ class SettingController extends GetxController { menuList.refresh(); } - // 同步设置 + /// 按菜单类型直接切换(供人脸设置页跳转人脸管理等场景使用)。 + void openMenu(SettingMenuType type) { + cuurentMenuType.value = type; + menuList.refresh(); + } + + /// 启动统一调度器并用已保存的 Cron 规则初始化草稿。 + Future _initScheduledTasks() async { + try { + await SyncCheckTool.instance.startAll(); + scheduledTaskCronDrafts.assignAll({ + for (final entry in SyncCheckTool.instance.configs.entries) + entry.key: entry.value.cron, + }); + } catch (e, stack) { + logger.e("|$_tag|定时任务初始化失败error:|$e|stack:|$stack|"); + } + } + + /// 更新指定任务的 Cron 草稿,输入变化后清除该任务旧校验错误。 + void setScheduledTaskCronDraft(ScheduledTaskType type, String value) { + scheduledTaskCronDrafts[type] = value; + scheduledTaskCronErrors.remove(type); + } + + /// 切换任务自动调度开关;关闭运行保障任务前要求操作人员确认。 + Future setScheduledTaskEnabled( + ScheduledTaskType type, + bool enabled, + ) async { + final config = SyncCheckTool.instance.configs[type]; + if (config == null || config.enabled == enabled) return; + if (!enabled && _isRuntimeGuardTask(type)) { + final confirmed = await appMessageDialog( + Get.context!, + '关闭后终端将不再自动执行该运行保障任务,是否继续?', + ); + if (!confirmed) return; + } + await _saveScheduledTaskConfig(config.copyWith(enabled: enabled)); + } + + /// 切换任务是否仅在完整空闲窗口自动执行,并持久化到结构化任务表。 + Future setScheduledTaskIdleOnly( + ScheduledTaskType type, + bool idleOnly, + ) async { + final config = SyncCheckTool.instance.configs[type]; + if (config == null || config.idleOnly == idleOnly) return; + await _saveScheduledTaskConfig(config.copyWith(idleOnly: idleOnly)); + } + + /// Cron 弹窗返回经过本地解析校验的表达式,Controller 仍在持久化边界再次校验。 + Future setScheduledTaskCron( + ScheduledTaskType type, + String expression, + ) async { + final config = SyncCheckTool.instance.configs[type]; + if (config == null) return; + final cron = expression.trim(); + if (!SyncCheckTool.isValidCron(cron)) { + Toast.error(msg: '请输入有效的 6 段 Cron 表达式'); + return; + } + if (cron == config.cron) return; + await _saveScheduledTaskConfig(config.copyWith(cron: cron)); + } + + /// 串联调度重建和 SQLite 保存,负责重复提交保护、草稿回退及用户反馈。 + Future _saveScheduledTaskConfig(ScheduledTaskConfig config) async { + if (savingScheduledTasks.contains(config.type)) return; + final previous = SyncCheckTool.instance.configs[config.type]; + savingScheduledTasks.add(config.type); + try { + await SyncCheckTool.instance.updateConfig(config); + scheduledTaskCronDrafts[config.type] = config.cron; + scheduledTaskCronErrors.remove(config.type); + Toast.success(msg: '任务配置已保存'); + } catch (e, stack) { + if (previous != null) { + scheduledTaskCronDrafts[config.type] = previous.cron; + } + logger.e( + "|$_tag|保存定时任务失败type:${config.type.name}|error:$e|stack:$stack|"); + Toast.error(msg: '任务配置保存失败,请重试'); + } finally { + savingScheduledTasks.remove(config.type); + } + } + + /// 手动触发指定任务,并根据立即执行、排队或合并结果向操作人员反馈。 + Future runScheduledTaskNow(ScheduledTaskType type) async { + final scheduler = SyncCheckTool.instance; + if (scheduler.runningTasks.contains(type) || + scheduler.pendingTasks.contains(type)) { + await scheduler.trigger(type); + Toast.success(msg: '已合并到待执行任务'); + return; + } + await scheduler.trigger(type); + if (scheduler.pendingTasks.contains(type)) { + Toast.success(msg: '任务已排队,将在安全窗口执行'); + return; + } + final record = scheduler.records[type]; + if (record?.success == true) { + Toast.success(msg: record?.message ?? '任务执行完成'); + } else { + Toast.error(msg: record?.message ?? '任务执行失败'); + } + } + + /// 返回关闭自动调度前需要二次确认的 MQTT、打印、广播和设备状态保障任务。 + bool _isRuntimeGuardTask(ScheduledTaskType type) => const { + ScheduledTaskType.mqReconnect, + ScheduledTaskType.printCheck, + ScheduledTaskType.broadcastCheck, + ScheduledTaskType.deviceStatus, + }.contains(type); + + // 同步设备信息(人脸设置页等入口使用) void syncConfig() async { final isNetwork = AppNetworkTool.isAutoNetworkMsg(); if (!isNetwork) return; @@ -163,84 +298,9 @@ class SettingController extends GetxController { } } - // 重新连接长链接 - void syncMq() async { - final isNetwork = AppNetworkTool.isAutoNetworkMsg(); - if (!isNetwork) return; - EasyLoading.show( - status: '同步中...', - maskType: EasyLoadingMaskType.black, - ); - try { - // 关闭长链接 - await MqManager.closeMqtt(); - await AppMqTool.mqRefresh(); - Toast.success(msg: "同步长连接成功"); - EasyLoading.dismiss(); - } catch (e, stack) { - Toast.error(msg: "同步长连接错误"); - logger.e("|$_tag|同步长连接error:|$e|stack:|$stack|"); - EasyLoading.dismiss(); - } - } - // 同步分类 - void syncClassify() async { - final isNetwork = AppNetworkTool.isAutoNetworkMsg(); - if (!isNetwork) return; - EasyLoading.show( - status: '同步中...', - maskType: EasyLoadingMaskType.black, - ); - try { - await SyncProductTool.syncClassify(); - Toast.success(msg: "同步分类成功"); - EasyLoading.dismiss(); - } catch (e, stack) { - Toast.error(msg: "同步分类错误"); - logger.e("|$_tag|同步分类error:|$e|stack:|$stack|"); - EasyLoading.dismiss(); - } - } - // 同步商品 - void syncProduct() async { - final isNetwork = AppNetworkTool.isAutoNetworkMsg(); - if (!isNetwork) return; - EasyLoading.show( - status: '同步中...', - maskType: EasyLoadingMaskType.black, - ); - try { - await SyncProductTool.syncProduct(); - EasyLoading.dismiss(); - Toast.success(msg: "同步商品成功"); - EasyLoading.dismiss(); - } catch (e, stack) { - Toast.error(msg: "同步商品错误"); - logger.e("|$_tag|同步商品error:|$e|stack:|$stack|"); - EasyLoading.dismiss(); - } - } - // 同步离线订单 - void syncOfflineOrder() async { - final isNetwork = AppNetworkTool.isAutoNetworkMsg(); - if (!isNetwork) return; - EasyLoading.show( - status: '同步中...', - maskType: EasyLoadingMaskType.black, - ); - try { - await SyncOrderTool.syncOfflineOrder(); - Toast.success(msg: "同步离线订单成功"); - EasyLoading.dismiss(); - } catch (e) { - Toast.error(msg: "同步离线订单错误"); - logger.e("|$_tag|同步离线订单error:|$e|"); - EasyLoading.dismiss(); - } - } // 备份sql void backupsSql() async { diff --git a/lib/pages/setting/face_setting_controller.dart b/lib/pages/setting/face_setting_controller.dart new file mode 100644 index 0000000..24a19ad --- /dev/null +++ b/lib/pages/setting/face_setting_controller.dart @@ -0,0 +1,279 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/pages/setting/controller.dart'; +import 'package:pj_supermarket_cloud/pages/setting/routes/navigation.dart'; + +/// 管理人脸插件状态、激活和内存库重建,避免硬件状态继续堆叠到设置主 Controller。 +class FaceSettingController extends GetxController { + static const String _tag = '人脸设置 Controller'; + + final status = Rxn(); + final progress = Rxn(); + final lastResult = Rxn(); + final isLoading = false.obs; + final isActivating = false.obs; + final isRegistering = false.obs; + final isSyncingConfig = false.obs; + final errorMessage = ''.obs; + final permissionCanOpenSettings = false.obs; + final showFullParameters = false.obs; + final recognitionConfiguration = Rxn(); + final savedRecognitionConfiguration = Rxn(); + final isLoadingRecognitionConfiguration = false.obs; + final isSavingRecognitionConfiguration = false.obs; + final showAdvancedRecognitionConfiguration = false.obs; + + StreamSubscription? _progressSubscription; + + @override + void onInit() { + super.onInit(); + _progressSubscription = FaceDiscernPlugs.registrationStream.listen( + _handleProgress, + onError: (Object error, StackTrace stack) { + logger.w('|$_tag|进度监听失败error:|$error|stack:|$stack|'); + }, + ); + refreshStatus(); + refreshRecognitionConfiguration(); + } + + @override + void onClose() { + _progressSubscription?.cancel(); + super.onClose(); + } + + Future refreshStatus({bool showLoading = true}) async { + if (showLoading) isLoading.value = true; + errorMessage.value = ''; + try { + final value = await AppFaceTool.getStatus(); + if (value == null) { + errorMessage.value = '无法读取人脸插件状态,请稍后重试'; + return; + } + status.value = value; + progress.value = value.registrationProgress; + lastResult.value = value.lastRegistrationResult; + isRegistering.value = value.registrationInProgress; + } catch (e, stack) { + errorMessage.value = '无法读取人脸插件状态,请稍后重试'; + logger.e('|$_tag|刷新状态失败error:|$e|stack:|$stack|'); + } finally { + if (showLoading) isLoading.value = false; + } + } + + Future refreshRecognitionConfiguration() async { + if (isLoadingRecognitionConfiguration.value) return; + isLoadingRecognitionConfiguration.value = true; + try { + final value = await FaceDiscernPlugs.getRecognizeConfiguration(); + savedRecognitionConfiguration.value = value; + recognitionConfiguration.value = value; + } on PlatformException catch (e, stack) { + logger.e('|$_tag|读取识别策略失败error:|$e|stack:|$stack|'); + Toast.error(msg: e.message ?? '读取人脸识别策略失败'); + } catch (e, stack) { + logger.e('|$_tag|读取识别策略失败error:|$e|stack:|$stack|'); + Toast.error(msg: '读取人脸识别策略失败'); + } finally { + isLoadingRecognitionConfiguration.value = false; + } + } + + void updateRecognitionConfiguration( + FaceRecognitionConfiguration Function(FaceRecognitionConfiguration value) + update, + ) { + final current = recognitionConfiguration.value; + if (current == null || isSavingRecognitionConfiguration.value) return; + recognitionConfiguration.value = update(current); + } + + Future saveRecognitionConfiguration() async { + final current = recognitionConfiguration.value; + if (current == null || isSavingRecognitionConfiguration.value) return; + isSavingRecognitionConfiguration.value = true; + try { + final saved = await FaceDiscernPlugs.setRecognizeConfiguration(current); + savedRecognitionConfiguration.value = saved; + recognitionConfiguration.value = saved; + final active = Get.isRegistered() && + Get.find().isActive; + Toast.success( + msg: active ? '配置已保存,将在当前识别结束后生效' : '人脸识别配置已保存', + ); + } on PlatformException catch (e, stack) { + logger.e('|$_tag|保存识别策略失败error:|$e|stack:|$stack|'); + Toast.error(msg: e.message ?? '保存人脸识别配置失败'); + } catch (e, stack) { + logger.e('|$_tag|保存识别策略失败error:|$e|stack:|$stack|'); + Toast.error(msg: '保存人脸识别配置失败'); + } finally { + isSavingRecognitionConfiguration.value = false; + } + } + + Future restoreDefaultRecognitionConfiguration() async { + if (isSavingRecognitionConfiguration.value) return; + final confirmed = await appMessageDialog( + Get.context!, + '是否还原为默认人脸识别策略?活体检测将关闭,相似度阈值将还原为 80%。', + ); + if (!confirmed) return; + isSavingRecognitionConfiguration.value = true; + try { + final restored = await FaceDiscernPlugs.resetRecognizeConfiguration(); + savedRecognitionConfiguration.value = restored; + recognitionConfiguration.value = restored; + final active = Get.isRegistered() && + Get.find().isActive; + Toast.success( + msg: active ? '已还原默认配置,将在当前识别结束后生效' : '已还原默认人脸识别配置', + ); + } on PlatformException catch (e, stack) { + logger.e('|$_tag|还原识别策略失败error:|$e|stack:|$stack|'); + Toast.error(msg: e.message ?? '还原默认配置失败'); + } catch (e, stack) { + logger.e('|$_tag|还原识别策略失败error:|$e|stack:|$stack|'); + Toast.error(msg: '还原默认配置失败'); + } finally { + isSavingRecognitionConfiguration.value = false; + } + } + + void toggleAdvancedRecognitionConfiguration() { + showAdvancedRecognitionConfiguration.toggle(); + } + + bool get recognitionConfigurationChanged => + recognitionConfiguration.value != savedRecognitionConfiguration.value; + + Future syncConfig() async { + if (isBusy) return; + if (!AppNetworkTool.isAutoNetworkMsg()) return; + isSyncingConfig.value = true; + errorMessage.value = ''; + try { + final setting = await SyncConfigTool.autoSyncSysInfo(); + if (setting == null) { + throw Exception('设备配置同步无返回'); + } + await Get.find().getSetting(); + await refreshStatus(showLoading: false); + Toast.success(msg: '设备配置同步完成'); + } catch (e, stack) { + errorMessage.value = '设备配置同步失败,请检查网络和设备绑定状态'; + logger.e('|$_tag|同步设备配置失败error:|$e|stack:|$stack|'); + Toast.error(msg: errorMessage.value); + } finally { + isSyncingConfig.value = false; + } + } + + Future activate() async { + if (isBusy) return; + isActivating.value = true; + errorMessage.value = ''; + permissionCanOpenSettings.value = false; + try { + final result = await AppFaceTool.activateSdk(); + permissionCanOpenSettings.value = result.canOpenSettings; + if (!result.success) { + errorMessage.value = result.message; + Toast.error(msg: result.message); + return; + } + Toast.success(msg: result.message); + await refreshStatus(showLoading: false); + } catch (e, stack) { + errorMessage.value = '人脸插件激活失败,请稍后重试'; + logger.e('|$_tag|激活失败error:|$e|stack:|$stack|'); + Toast.error(msg: errorMessage.value); + } finally { + isActivating.value = false; + } + } + + Future rebuildLibrary() async { + if (isBusy) return; + final currentStatus = status.value; + if (currentStatus == null || !currentStatus.activated) { + Toast.error(msg: '请先激活人脸插件'); + return; + } + if (currentStatus.databaseCount == 0) { + goFaceManage(); + return; + } + + final confirmed = await appMessageDialog( + Get.context!, + '是否重新注册全部人脸?注册期间可离开此页面,支付暂不能使用人脸识别。', + ); + if (!confirmed) return; + + isRegistering.value = true; + errorMessage.value = ''; + try { + final result = await FaceDiscernPlugs.rebuildFaceLibrary(); + lastResult.value = result; + if (result.failedCount > 0) { + errorMessage.value = result.message; + Toast.error(msg: result.message); + } else { + Toast.success(msg: result.message); + } + } on PlatformException catch (e, stack) { + errorMessage.value = e.code == 'REGISTRATION_IN_PROGRESS' + ? '人脸库正在注册,请稍候' + : (e.message ?? '人脸库注册失败,请稍后重试'); + logger.e('|$_tag|重建人脸库失败error:|$e|stack:|$stack|'); + Toast.error(msg: errorMessage.value); + } catch (e, stack) { + errorMessage.value = '人脸库注册失败,请稍后重试'; + logger.e('|$_tag|重建人脸库失败error:|$e|stack:|$stack|'); + Toast.error(msg: errorMessage.value); + } finally { + isRegistering.value = false; + await refreshStatus(showLoading: false); + } + } + + Future openPermissionSettings() async { + await openAppSettings(); + } + + /// 从人脸插件诊断页切换到独立的人脸管理路由。 + void goFaceManage() { + SettingNavigation.openMenu(SettingMenuType.faceSetting); + } + + void toggleParameterVisibility() { + showFullParameters.toggle(); + } + + SysSetting? get sysSetting => Get.find().sysSetting.value; + + bool get isBusy => + isLoading.value || + isActivating.value || + isRegistering.value || + isSyncingConfig.value || + isSavingRecognitionConfiguration.value; + + void _handleProgress(FaceRegistrationProgress value) { + progress.value = value; + isRegistering.value = value.inProgress; + if (!value.inProgress && value.phase != 'idle') { + refreshStatus(showLoading: false); + } + } +} diff --git a/lib/pages/setting/index.dart b/lib/pages/setting/index.dart index b16b2f8..1a6f668 100644 --- a/lib/pages/setting/index.dart +++ b/lib/pages/setting/index.dart @@ -1,4 +1,7 @@ library; export './controller.dart'; +export './face_setting_controller.dart'; export './view.dart'; +export './sync_logs/controller.dart'; +export './sync_logs/view.dart'; diff --git a/lib/pages/setting/routes/navigation.dart b/lib/pages/setting/routes/navigation.dart new file mode 100644 index 0000000..ca24ad5 --- /dev/null +++ b/lib/pages/setting/routes/navigation.dart @@ -0,0 +1,26 @@ +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; +import 'package:pj_supermarket_cloud/pages/setting/controller.dart'; + +/// 设置模块内部导航入口。 +/// +/// 适配自 biz_xfj_cloud 的同名类:目标项目设置页为单页左菜单结构, +/// 任务日志跳转改走全局 GetX 路由,菜单切换复用 SettingController。 +abstract final class SettingNavigation { + /// 打开指定任务的执行日志页。 + static Future openTaskLogs(ScheduledTaskType type) async { + await Get.toNamed(RouteNames.settingSyncLogs, arguments: type); + } + + /// 从日志页返回定时任务设置。 + static void backToSync() { + Get.back(); + } + + /// 切换设置左侧菜单到指定功能。 + static void openMenu(SettingMenuType type) { + final controller = Get.find(); + controller.openMenu(type); + } +} diff --git a/lib/pages/setting/sync_logs/controller.dart b/lib/pages/setting/sync_logs/controller.dart new file mode 100644 index 0000000..728c20b --- /dev/null +++ b/lib/pages/setting/sync_logs/controller.dart @@ -0,0 +1,98 @@ +import 'dart:async'; + +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 定时任务日志页面控制器,负责 SQLite 查询、来源筛选和刷新状态。 +class ScheduledTaskLogsController extends GetxController { + /// 日志表格默认每页数量,与 1080p 设置页可用高度和现有交易记录密度保持一致。 + static const int _defaultPageSize = 10; + + /// 使用路由参数中的稳定任务类型创建日志控制器。 + ScheduledTaskLogsController(this.taskType); + + /// 当前页面固定查看的任务类型,生命周期内不会变化。 + final ScheduledTaskType taskType; + + /// 当前筛选条件和页码对应的结构化执行日志,按数据库主键倒序排列。 + final RxList logs = [].obs; + + /// 当前页码,从 1 开始,由分页组件修改并在查询结果越界时自动校正。 + final RxInt currentPage = 1.obs; + + /// 每页固定展示数量;日志行可能展开错误详情,因此保持与交易记录相同的十条密度。 + final int pageSize = _defaultPageSize; + + /// 当前任务和来源筛选下的日志总数,直接来自 SQLite 计数查询。 + final RxInt total = 0.obs; + + /// 当前依赖来源筛选;为空表示查询 MQTT、HTTP、插件、文件和本地全部来源。 + final Rxn sourceType = + Rxn(); + + /// 是否正在读取 SQLite,用于展示加载状态并禁止重复刷新。 + final RxBool isLoading = false.obs; + + /// 最近一次查询失败时面向操作人员的摘要;查询成功后清空。 + final Rxn errorMessage = Rxn(); + + /// 查询序号用于丢弃快速切换筛选时晚返回的旧结果,避免列表与筛选条件错位。 + int _requestSerial = 0; + + /// 控制器创建后立即加载当前任务的全部来源日志。 + @override + void onInit() { + super.onInit(); + unawaited(loadLogs()); + } + + /// 选择新的依赖来源并重新查询;重复点击当前筛选不会制造额外数据库请求。 + void selectSource(ScheduledTaskSourceType? value) { + if (sourceType.value == value) return; + sourceType.value = value; + currentPage.value = 1; + unawaited(loadLogs()); + } + + /// 按当前任务、来源和页码读取日志,只应用最后一次请求的结果。 + Future loadLogs({bool resetPage = false}) async { + if (resetPage) currentPage.value = 1; + final requestSerial = ++_requestSerial; + isLoading.value = true; + errorMessage.value = null; + try { + final result = await SyncCheckTool.instance.loadLogPage( + taskType, + sourceType: sourceType.value, + currentPage: currentPage.value, + pageSize: pageSize, + ); + if (isClosed || requestSerial != _requestSerial) return; + currentPage.value = result.currentPage; + total.value = result.total; + logs.assignAll(result.records); + } catch (error, stack) { + logger.e( + '|定时任务日志|读取失败task:|${taskType.name}|error:|$error|stack:|$stack|', + ); + if (isClosed || requestSerial != _requestSerial) return; + currentPage.value = 1; + total.value = 0; + logs.clear(); + errorMessage.value = '日志读取失败,请稍后重试'; + } finally { + if (!isClosed && requestSerial == _requestSerial) { + isLoading.value = false; + } + } + } + + /// 切换到有效页码并读取对应日志;重复点击当前页不会发起额外 SQLite 查询。 + Future changePage(int page) async { + final totalPages = total.value == 0 ? 1 : (total.value / pageSize).ceil(); + if (page < 1 || page > totalPages || page == currentPage.value) return; + currentPage.value = page; + await loadLogs(); + } +} diff --git a/lib/pages/setting/sync_logs/view.dart b/lib/pages/setting/sync_logs/view.dart new file mode 100644 index 0000000..230481d --- /dev/null +++ b/lib/pages/setting/sync_logs/view.dart @@ -0,0 +1,498 @@ +import 'package:common_utils/common_utils.dart'; +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +import '../routes/navigation.dart'; +import 'controller.dart'; + +/// 紧凑型定时任务结构化日志页面。 +/// +/// 页面替代原弹窗,使用稳定表格列展示状态、依赖、触发方式、时间、耗时和结果, +/// 错误堆栈仅在用户主动展开时显示,保证常规巡检时的信息密度。 +class ScheduledTaskLogsPage extends GetView { + /// 创建由路由 Binding 提供控制器的任务日志页面。 + const ScheduledTaskLogsPage({super.key}); + + /// 构建页面工具栏、来源筛选、日志表格和分页栏,各响应区域分别局部刷新。 + @override + Widget build(BuildContext context) => [ + _buildToolbar(context), + SizedBox(height: 10.h), + _buildSourceFilters(context), + SizedBox(height: 10.h), + Obx(() => _buildLogBody(context)).expanded(), + SizedBox(height: XfjDesignTokens.cardGap.h), + Obx( + () => appPage( + context, + pageNo: controller.currentPage.value, + pageSize: controller.pageSize, + total: controller.total.value, + itemLabel: '条任务日志', + onPageChanged: controller.changePage, + ), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch); + + /// 构建返回入口、任务与依赖名称、当前条数以及手动刷新按钮。 + Widget _buildToolbar(BuildContext context) { + final theme = FTheme.of(context); + return [ + Tooltip( + message: '返回同步设置', + child: InkWell( + onTap: SettingNavigation.backToSync, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: FIcon( + FAssets.icons.arrowLeft, + size: 22.sp, + color: XfjDesignTokens.ink, + ).center().constrained(width: 48.w, height: 48.h), + ), + ), + [ + Text( + '${SyncCheckTool.taskTitle(controller.taskType)}日志', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xl.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 2.h), + Text( + SyncCheckTool.taskSourceName(controller.taskType), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith(color: XfjDesignTokens.muted), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), + Obx( + () => Text( + '${controller.total.value} 条', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + SizedBox(width: 12.w), + Obx( + () => FButton( + style: FButtonStyle.outline, + onPress: controller.isLoading.value ? null : controller.loadLogs, + prefix: controller.isLoading.value + ? SizedBox.square( + dimension: 17.w, + child: const CircularProgressIndicator(strokeWidth: 2), + ) + : FIcon(FAssets.icons.refreshCw, size: 17.sp), + label: Text('刷新', style: theme.typography.sm), + ).constrained(minWidth: 104.w, minHeight: 44.h), + ), + ] + .toRowSpace(space: 12.w) + .padding(horizontal: 4.w) + .constrained(minHeight: 52.h); + } + + /// 构建全部来源与五类依赖的紧凑筛选条,窄宽度时允许自然换行。 + Widget _buildSourceFilters(BuildContext context) => Obx( + () => [ + _SourceFilter( + label: '全部', + selected: controller.sourceType.value == null, + onTap: () => controller.selectSource(null), + ), + for (final source in ScheduledTaskSourceType.values) + _SourceFilter( + label: _sourceLabel(source), + selected: controller.sourceType.value == source, + onTap: () => controller.selectSource(source), + ), + ] + .toWrap(spacing: 8.w, runSpacing: 8.h) + .padding(horizontal: 4.w, vertical: 2.h), + ); + + /// 根据查询状态构建加载、失败、空数据或带固定表头的日志列表。 + Widget _buildLogBody(BuildContext context) { + if (controller.isLoading.value && controller.logs.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + final error = controller.errorMessage.value; + if (error != null) { + return _LogState( + icon: FAssets.icons.circleAlert, + message: error, + actionLabel: '重新加载', + onAction: controller.loadLogs, + ); + } + if (controller.logs.isEmpty) { + return _LogState( + icon: FAssets.icons.scrollText, + message: '暂无符合当前来源条件的任务执行日志', + ); + } + return [ + const _LogTableHeader(), + ListView.separated( + padding: EdgeInsets.zero, + itemCount: controller.logs.length, + separatorBuilder: (_, __) => const Divider( + height: 1, + thickness: 1, + color: XfjDesignTokens.line, + ), + itemBuilder: (_, index) => _TaskLogRow(log: controller.logs[index]), + ).expanded(), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch).decorated( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ); + } +} + +/// 日志表格固定列头,与每条日志行使用同一套宽度约束。 +class _LogTableHeader extends StatelessWidget { + /// 创建无交互的日志表头。 + const _LogTableHeader(); + + /// 构建状态、依赖、开始时间、耗时和执行结果列名。 + @override + Widget build(BuildContext context) { + final style = FTheme.of(context).typography.xs.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ); + return [ + Text('状态', style: style).width(94.w), + Text('依赖 / 触发', style: style).width(188.w), + Text('开始时间', style: style).width(190.w), + Text('耗时', style: style).width(88.w), + Text('执行结果', style: style).expanded(), + SizedBox(width: 44.w), + ].toRow().padding(horizontal: 14.w, vertical: 9.h).decorated( + color: XfjDesignTokens.control, + border: const Border(bottom: BorderSide(color: XfjDesignTokens.line)), + ); + } +} + +/// 单条结构化任务日志,错误内容按需展开而不占用其他行空间。 +class _TaskLogRow extends StatefulWidget { + /// 使用数据库返回的不可变日志快照创建列表行。 + const _TaskLogRow({required this.log}); + + /// 当前行展示的结构化任务日志。 + final ScheduledTaskLogEntity log; + + /// 创建仅管理本行错误详情展开状态的 State。 + @override + State<_TaskLogRow> createState() => _TaskLogRowState(); +} + +/// 单条任务日志的局部展开状态。 +class _TaskLogRowState extends State<_TaskLogRow> { + /// 是否展示异常类型、异常摘要和堆栈,默认关闭以保持列表紧凑。 + bool _expanded = false; + + /// 构建固定列数据以及可选的错误详情区域。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final visual = _statusVisual(widget.log.status); + final hasDetail = widget.log.errorMessage?.isNotEmpty == true || + widget.log.errorStack?.isNotEmpty == true; + final rowStyle = theme.typography.sm; + return InkWell( + onTap: hasDetail ? _toggleExpanded : null, + child: [ + [ + [ + FIcon(visual.icon, color: visual.color, size: 19.sp), + Text( + visual.label, + maxLines: 1, + style: rowStyle.copyWith( + color: visual.color, + fontWeight: FontWeight.w700, + ), + ), + ].toRowSpace(space: 7.w).width(94.w), + [ + _Badge(label: _sourceLabelName(widget.log.sourceType)), + _Badge(label: _triggerLabel(widget.log.triggerType)), + ].toWrap(spacing: 5.w, runSpacing: 4.h).width(188.w), + Text( + DateUtil.formatDate( + widget.log.startedAt, + format: DateFormats.full, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: rowStyle.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ).width(190.w), + Text( + '${widget.log.durationMs} ms', + maxLines: 1, + style: rowStyle.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ).width(88.w), + [ + Text( + widget.log.message, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: rowStyle.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + if (_logMetrics(widget.log) case final metrics?) + Text( + metrics, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), + FIcon( + hasDetail + ? (_expanded + ? FAssets.icons.chevronUp + : FAssets.icons.chevronDown) + : FAssets.icons.minus, + color: hasDetail ? XfjDesignTokens.muted : XfjDesignTokens.line, + size: 18.sp, + ).center().width(44.w), + ] + .toRow(crossAxisAlignment: CrossAxisAlignment.center) + .padding(horizontal: 14.w, vertical: 10.h) + .constrained(minHeight: 58.h), + if (_expanded && hasDetail) _buildErrorDetail(context), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.stretch), + ); + } + + /// 切换本行错误详情,不触发整页日志列表重建。 + void _toggleExpanded() { + setState(() => _expanded = !_expanded); + } + + /// 构建可选择复制的异常详情,内容来自已截断并脱敏的结构化日志字段。 + Widget _buildErrorDetail(BuildContext context) => SelectableText( + [ + widget.log.errorType, + widget.log.errorMessage, + widget.log.errorStack, + ].whereType().where((value) => value.isNotEmpty).join('\n'), + style: FTheme.of(context).typography.xs.copyWith( + color: XfjDesignTokens.muted, + fontFamily: 'monospace', + height: 1.45, + ), + ) + .padding(all: 10.w) + .decorated( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ) + .padding(left: 108.w, right: 58.w, bottom: 10.h); +} + +/// MQTT、HTTP、插件、文件和本地来源的紧凑筛选按钮。 +class _SourceFilter extends StatelessWidget { + /// 创建由日志控制器管理选中态的来源筛选项。 + const _SourceFilter({ + required this.label, + required this.selected, + required this.onTap, + }); + + /// 筛选按钮展示的短名称。 + final String label; + + /// 当前来源是否处于选中状态。 + final bool selected; + + /// 点击后切换来源并重新查询的回调。 + final VoidCallback onTap; + + /// 构建 44dp 触控热区内的紧凑文字筛选项。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Text( + label, + maxLines: 1, + softWrap: false, + textAlign: TextAlign.center, + style: theme.typography.sm.copyWith( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ) + .alignment(Alignment.center) + .constrained(width: 78.w, height: 40.h) + .decorated( + color: selected + ? XfjDesignTokens.primarySoft + : XfjDesignTokens.surface, + border: Border.all( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ) + .padding(vertical: 2.h), + ); + } +} + +/// 日志来源和触发方式使用的轻量元数据标签。 +class _Badge extends StatelessWidget { + /// 使用指定短文本创建无交互标签。 + const _Badge({required this.label}); + + /// 标签展示的来源或触发方式名称。 + final String label; + + /// 构建不改变表格行高的紧凑标签。 + @override + Widget build(BuildContext context) => Text( + label, + maxLines: 1, + style: FTheme.of(context).typography.xs.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ).padding(horizontal: 7.w, vertical: 2.h).decorated( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ); +} + +/// 日志读取失败或无数据时共用的页面状态视图。 +class _LogState extends StatelessWidget { + /// 使用状态图标、说明和可选重试动作创建状态页。 + const _LogState({ + required this.icon, + required this.message, + this.actionLabel, + this.onAction, + }); + + /// 状态语义对应的 Forui 图标。 + final SvgAsset icon; + + /// 面向终端操作人员的状态说明。 + final String message; + + /// 可选操作按钮名称;为空时不展示按钮。 + final String? actionLabel; + + /// 可选操作回调,通常用于失败后的重新加载。 + final VoidCallback? onAction; + + /// 构建居中的状态内容和可选操作按钮。 + @override + Widget build(BuildContext context) => [ + FIcon(icon, size: 40.sp, color: XfjDesignTokens.muted), + SizedBox(height: 10.h), + Text( + message, + textAlign: TextAlign.center, + style: FTheme.of(context).typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + if (actionLabel != null && onAction != null) ...[ + SizedBox(height: 12.h), + FButton( + style: FButtonStyle.outline, + onPress: onAction, + label: Text(actionLabel!), + ).constrained(minWidth: 120.w, minHeight: 44.h), + ], + ].toColumn(mainAxisSize: MainAxisSize.min).center(); +} + +/// 将持久化状态转换为日志列表使用的中文名称、语义颜色和图标。 +({String label, Color color, SvgAsset icon}) _statusVisual(String status) => + switch (status) { + 'success' => ( + label: '成功', + color: XfjDesignTokens.success, + icon: FAssets.icons.circleCheck, + ), + 'failed' => ( + label: '失败', + color: XfjDesignTokens.danger, + icon: FAssets.icons.circleAlert, + ), + 'skipped' => ( + label: '跳过', + color: XfjDesignTokens.warning, + icon: FAssets.icons.circleMinus, + ), + _ => ( + label: '执行中', + color: XfjDesignTokens.primary, + icon: FAssets.icons.refreshCw, + ), + }; + +/// 将任务依赖枚举转换为筛选按钮使用的短名称。 +String _sourceLabel(ScheduledTaskSourceType type) => switch (type) { + ScheduledTaskSourceType.mqtt => 'MQTT', + ScheduledTaskSourceType.http => 'HTTP', + ScheduledTaskSourceType.plugin => '插件', + ScheduledTaskSourceType.file => '文件', + ScheduledTaskSourceType.local => '本地', + }; + +/// 将数据库依赖编码转换为短名称,未知编码保持原样便于诊断兼容问题。 +String _sourceLabelName(String source) { + for (final type in ScheduledTaskSourceType.values) { + if (type.name == source) return _sourceLabel(type); + } + return source; +} + +/// 将数据库中的任务触发来源转换为日志表格中文标签。 +String _triggerLabel(String trigger) => switch (trigger) { + 'cron' => '定时', + 'mqtt' => 'MQTT 拉取', + 'system' => '系统', + _ => '手动', + }; + +/// 生成文件类任务的扫描统计摘要;非文件任务未记录扫描数时返回空。 +String? _logMetrics(ScheduledTaskLogEntity log) { + if (log.scannedCount == null) return null; + return '扫描 ${log.scannedCount ?? 0} · 跳过 ${log.skippedCount ?? 0} · ' + '成功 ${log.successCount ?? 0} · 失败 ${log.failureCount ?? 0}'; +} diff --git a/lib/pages/setting/view.dart b/lib/pages/setting/view.dart index ef0f679..941219e 100644 --- a/lib/pages/setting/view.dart +++ b/lib/pages/setting/view.dart @@ -118,6 +118,11 @@ class SettingPage extends GetView { return const SizedBox.shrink(); } return settingProduct(context); + case SettingMenuType.facePluginSetting: + if (!isBand) { + return const SizedBox.shrink(); + } + return settingFacePlugin(context); case SettingMenuType.versionSetting: if (!isBand) { return const SizedBox.shrink(); diff --git a/lib/pages/setting/widgets/index.dart b/lib/pages/setting/widgets/index.dart index 79e4037..3b8686a 100644 --- a/lib/pages/setting/widgets/index.dart +++ b/lib/pages/setting/widgets/index.dart @@ -3,6 +3,7 @@ library; export './setting_backup.dart'; export './setting_baseInfo.dart'; export './setting_face.dart'; +export './setting_face_plugin.dart'; export './setting_pay.dart'; export './setting_product.dart'; export './setting_sync.dart'; diff --git a/lib/pages/setting/widgets/setting_components.dart b/lib/pages/setting/widgets/setting_components.dart new file mode 100644 index 0000000..fbc727f --- /dev/null +++ b/lib/pages/setting/widgets/setting_components.dart @@ -0,0 +1,515 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 设置页通用分组,统一标题层级、边框和圆角。 +class SettingGroup extends StatelessWidget { + /// 创建设置分组;[dense] 仅用于需要提高信息密度的页面。 + const SettingGroup({ + super.key, + required this.title, + required this.child, + this.dense = false, + }); + + /// 分组标题。 + final String title; + + /// 分组卡片中的实际设置内容。 + final Widget child; + + /// 是否使用紧凑标题层级;只压缩排版,不改变分组内容的触控尺寸。 + final bool dense; + + /// 构建标题和白色分组卡片。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: (dense ? theme.typography.base : theme.typography.lg).copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: (dense ? 8 : 12).h), + Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + ), + child: child, + ), + ], + ); + } +} + +class SettingSegmentedSelector extends StatelessWidget { + const SettingSegmentedSelector({ + super.key, + required this.label, + required this.value, + required this.options, + required this.onChanged, + this.showDivider = true, + }); + + final String label; + final T value; + final Map options; + final ValueChanged onChanged; + final bool showDivider; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final optionStyle = theme.typography.base.copyWith( + color: XfjDesignTokens.muted, + fontWeight: FontWeight.w600, + ); + final entries = options.entries.toList(growable: false); + return [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ).width(230.w), + LayoutBuilder( + builder: (context, constraints) { + if (entries.isEmpty) return const SizedBox.shrink(); + final gap = 4.w; + final regularHorizontalPadding = + 20.w * XfjDesignTokens.buttonSizeScale; + final preferredWidth = entries.fold(8.w, (width, entry) { + final painter = TextPainter( + text: TextSpan(text: entry.value, style: optionStyle), + maxLines: 1, + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + final optionWidth = + (painter.width + regularHorizontalPadding * 2) + .clamp(112.w, double.infinity); + return width + optionWidth; + }) + + gap * (entries.length - 1); + final fillAvailableWidth = constraints.maxWidth.isFinite && + preferredWidth > constraints.maxWidth; + + // 支付方式较多时等分可用宽度,保留 48dp 点击高度并让长文案省略, + // 避免固定最小宽度在页面设置的窄内容区产生横向溢出。 + final optionWidgets = entries + .map( + (entry) => _segmentedOption( + entry: entry, + active: value == entry.key, + style: optionStyle, + fillAvailableWidth: fillAvailableWidth, + ), + ) + .toList(growable: false); + return optionWidgets + .toRow( + mainAxisSize: + fillAvailableWidth ? MainAxisSize.max : MainAxisSize.min, + separator: SizedBox(width: gap), + ) + .padding(all: 4.w) + .decorated( + color: const Color(0xFFF0F2F5), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ) + .alignRight(); + }, + ).expanded(), + ] + .toRowSpace(space: 16.w) + .padding(horizontal: 20.w, vertical: 12.h) + .constrained(minHeight: 72.h) + .decorated( + border: showDivider + ? const Border(bottom: BorderSide(color: XfjDesignTokens.line)) + : null, + ); + } + + Widget _segmentedOption({ + required MapEntry entry, + required bool active, + required TextStyle style, + required bool fillAvailableWidth, + }) { + final horizontalPadding = + fillAvailableWidth ? 8.w : 20.w * XfjDesignTokens.buttonSizeScale; + final option = InkWell( + onTap: () => onChanged(entry.key), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Text( + entry.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: style.copyWith( + color: active ? XfjDesignTokens.primary : XfjDesignTokens.muted, + ), + ) + .center() + .padding( + horizontal: horizontalPadding, + vertical: 6.h * XfjDesignTokens.buttonSizeScale, + ) + .constrained( + minWidth: fillAvailableWidth ? 0 : 112.w, + minHeight: 48.h, + ) + .decorated( + color: active ? Colors.white : Colors.transparent, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + boxShadow: active ? XfjDesignTokens.surfaceShadow : null, + ), + ); + return fillAvailableWidth ? option.expanded() : option; + } +} + +class SettingInfoRow extends StatelessWidget { + const SettingInfoRow({ + super.key, + required this.icon, + required this.label, + this.value, + this.actionLabel, + this.onTap, + this.showDivider = true, + this.trailing, + this.subtitle, + }); + + final SvgAsset icon; + final String label; + final String? value; + final String? actionLabel; + final VoidCallback? onTap; + final bool showDivider; + final Widget? trailing; + final String? subtitle; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final hasAction = actionLabel != null; + return InkWell( + onTap: onTap, + child: Container( + constraints: BoxConstraints(minHeight: subtitle == null ? 68.h : 80.h), + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h), + decoration: BoxDecoration( + border: showDivider + ? const Border(bottom: BorderSide(color: XfjDesignTokens.line)) + : null, + ), + child: Row( + children: [ + Container( + width: 40.w, + height: 40.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: FIcon(icon, color: XfjDesignTokens.muted, size: 22.sp), + ), + SizedBox(width: 16.w), + SizedBox( + width: subtitle == null ? 230.w : 520.w, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w600, + ), + ), + if (subtitle != null) ...[ + SizedBox(height: 4.h), + Text( + subtitle!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ], + ), + ), + SizedBox(width: 16.w), + Expanded( + child: value == null + ? const SizedBox.shrink() + : Text( + value!, + maxLines: subtitle == null ? 1 : 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + if (trailing != null) ...[SizedBox(width: 16.w), trailing!], + if (hasAction) ...[ + SizedBox(width: 16.w), + SizedBox( + width: 148.w, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( + child: Text( + actionLabel!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + SizedBox(width: 6.w), + FIcon( + actionLabel == '复制' + ? FAssets.icons.copy + : FAssets.icons.chevronRight, + color: XfjDesignTokens.primary, + size: 18.sp, + ), + ], + ), + ), + ], + ], + ), + ), + ); + } +} + +/// 设置页通用提示条,用图标、文字和语义颜色反馈说明或状态。 +class SettingNotice extends StatelessWidget { + /// 创建提示条;[dense] 适用于紧凑表单,但不会删减提示内容。 + const SettingNotice({ + super.key, + required this.text, + this.color = XfjDesignTokens.muted, + this.background = XfjDesignTokens.control, + this.borderColor, + this.icon, + this.dense = false, + }); + + /// 提示正文。 + final String text; + + /// 提示正文和图标的前景色。 + final Color color; + + /// 提示条背景色。 + final Color background; + + /// 可选边框颜色,为空时不绘制边框。 + final Color? borderColor; + + /// 可选语义图标。 + final SvgAsset? icon; + + /// 是否使用紧凑提示样式;保留完整文案和语义,仅降低字号与内边距。 + final bool dense; + + /// 构建可随宽度换行的提示内容。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Container( + width: double.infinity, + padding: EdgeInsets.symmetric( + horizontal: (dense ? 12 : 16).w, + vertical: (dense ? 10 : 16).h, + ), + decoration: BoxDecoration( + color: background, + border: borderColor == null ? null : Border.all(color: borderColor!), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + children: [ + if (icon != null) ...[ + FIcon(icon!, color: color, size: (dense ? 18 : 20).sp), + SizedBox(width: (dense ? 8 : 10).w), + ], + Expanded( + child: Text( + text, + style: (dense ? theme.typography.xs : theme.typography.sm) + .copyWith(color: color), + ), + ), + ], + ), + ); + } +} + +class SettingEmpty extends StatelessWidget { + const SettingEmpty({super.key, required this.message, this.icon}); + + final String message; + final SvgAsset? icon; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FIcon( + icon ?? FAssets.icons.inbox, + color: XfjDesignTokens.muted, + size: 48.sp, + ), + SizedBox(height: 12.h), + Text( + message, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ], + ), + ); + } +} + +/// 设置页通用操作按钮,提供主要、普通和危险三种层级。 +class SettingActionButton extends StatelessWidget { + /// 创建具有真实禁用态和最小触控高度的操作按钮。 + const SettingActionButton({ + super.key, + required this.label, + required this.onTap, + this.icon, + this.primary = false, + this.danger = false, + this.enabled = true, + this.dense = false, + }); + + /// 按钮文字。 + final String label; + + /// 按钮处于可用状态时的点击回调。 + final VoidCallback onTap; + + /// 可选前置图标。 + final SvgAsset? icon; + + /// 是否按页面唯一主要操作展示。 + final bool primary; + + /// 是否按危险操作展示。 + final bool danger; + + /// 是否允许点击;禁用时同时移除回调并降低视觉强调。 + final bool enabled; + + /// 是否使用紧凑视觉样式;点击高度仍固定不小于 48dp。 + final bool dense; + + /// 构建保持至少 48dp 点击高度的操作按钮。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final foreground = danger + ? XfjDesignTokens.danger + : primary + ? Colors.white + : XfjDesignTokens.primary; + final background = primary + ? XfjDesignTokens.primary + : danger + ? const Color(0xFFFFF3F2) + : XfjDesignTokens.surface; + final sizeScale = XfjDesignTokens.buttonSizeScale; + return Opacity( + opacity: enabled ? 1 : 0.45, + child: InkWell( + onTap: enabled ? onTap : null, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Container( + constraints: BoxConstraints(minHeight: 48.h), + padding: EdgeInsets.symmetric( + horizontal: (dense ? 14 : 18).w * sizeScale, + vertical: (dense ? 8 : 10).h * sizeScale, + ), + decoration: BoxDecoration( + color: background, + border: Border.all( + color: primary + ? XfjDesignTokens.primary + : danger + ? XfjDesignTokens.danger.withValues(alpha: 0.35) + : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + FIcon( + icon!, + color: foreground, + size: (dense ? 18 : 20).sp, + ), + SizedBox(width: (dense ? 6 : 8).w), + ], + Text( + label, + style: (dense ? theme.typography.sm : theme.typography.base) + .copyWith( + color: foreground, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/setting/widgets/setting_cron_rule_dialog.dart b/lib/pages/setting/widgets/setting_cron_rule_dialog.dart new file mode 100644 index 0000000..f91fdc6 --- /dev/null +++ b/lib/pages/setting/widgets/setting_cron_rule_dialog.dart @@ -0,0 +1,311 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; + +/// 打开 Cron 规则选择弹窗,确认时返回校验后的六段表达式,取消时返回空。 +Future showScheduledCronRuleDialog( + BuildContext context, { + required String currentExpression, +}) => + showAppDialog( + context: context, + barrierDismissible: false, + canPop: false, + builder: (_) => _ScheduledCronRuleDialog( + currentExpression: currentExpression, + ), + ); + +/// 支持预设筛选和自定义输入的 Cron 规则弹窗。 +class _ScheduledCronRuleDialog extends StatefulWidget { + /// 使用任务当前表达式创建弹窗,便于定位已选预设并支持原值编辑。 + const _ScheduledCronRuleDialog({required this.currentExpression}); + + /// 当前任务已经保存的六段 Cron 表达式。 + final String currentExpression; + + /// 创建持有输入控制器、预设分组和校验错误的弹窗状态。 + @override + State<_ScheduledCronRuleDialog> createState() => + _ScheduledCronRuleDialogState(); +} + +/// Cron 规则弹窗的本地编辑状态,不直接写数据库或修改调度器。 +class _ScheduledCronRuleDialogState extends State<_ScheduledCronRuleDialog> { + /// 自定义表达式输入控制器,初始值来自任务当前配置。 + late final TextEditingController _controller; + + /// 当前展示的预设分组,默认显示按间隔规则。 + ScheduledCronPresetGroup _group = ScheduledCronPresetGroup.interval; + + /// 最近一次本地 Cron 校验错误;输入变化后立即清空。 + String? _error; + + /// 初始化输入值,并在当前表达式命中预设时自动切换到对应分组。 + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.currentExpression); + for (final preset in SyncCheckTool.cronPresets) { + if (preset.expression == widget.currentExpression) { + _group = preset.group; + break; + } + } + } + + /// 释放仅属于本弹窗的文本控制器。 + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + /// 构建分组预设、自定义输入及确认操作,所有状态只在弹窗局部刷新。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final dialogWidth = MediaQuery.sizeOf(context).width * 0.6; + final presets = SyncCheckTool.cronPresets + .where((item) => item.group == _group) + .toList(growable: false); + return AppDialog( + title: Text( + '选择定时规则', + style: theme.typography.xl2.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + subtitle: Text('共 ${SyncCheckTool.cronPresets.length} 条常用预设'), + leading: FIcon( + FAssets.icons.clock, + color: XfjDesignTokens.primary, + size: 26.sp, + ).center().width(44.w).height(44.h), + width: dialogWidth, + maxWidth: dialogWidth, + height: MediaQuery.sizeOf(context).height * 0.86, + onClose: () => Navigator.of(context).pop(), + semanticLabel: '定时任务 Cron 规则弹框', + content: [ + Text( + 'Cron 表达式', + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + FTextField( + controller: _controller, + hint: '秒 分 时 日 月 周,例如 */30 * * * * *', + maxLines: 1, + textAlign: TextAlign.center, + textAlignVertical: TextAlignVertical.center, + onChange: (_) { + if (_error != null) setState(() => _error = null); + }, + onSubmit: (_) => _save(), + ).constrained(minHeight: 58.h), + if (_error != null) + Text( + _error!, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.danger, + fontWeight: FontWeight.w600, + ), + ), + [ + for (final group in ScheduledCronPresetGroup.values) + _GroupButton( + label: _groupLabel(group), + selected: _group == group, + onTap: () => setState(() => _group = group), + ).expanded(), + ].toRowSpace(space: 8.w), + LayoutBuilder( + builder: (context, constraints) { + final columns = constraints.maxWidth >= 1000.w ? 5 : 4; + final gap = 10.w; + final width = + (constraints.maxWidth - gap * (columns - 1)) / columns; + return Wrap( + spacing: gap, + runSpacing: 10.h, + children: [ + for (final preset in presets) + _PresetButton( + preset: preset, + selected: _controller.text.trim() == preset.expression, + onTap: () { + _controller.text = preset.expression; + setState(() => _error = null); + }, + ).width(width), + ], + ); + }, + ).scrollable().expanded(), + const Text('使用本地时区;不支持 Quartz 专属的 L、W、# 语法。') + .padding(all: 10.w) + .decorated( + color: XfjDesignTokens.control, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + ] + .toColumnSpace( + crossAxisAlignment: CrossAxisAlignment.stretch, + space: 12.h, + ) + .constrained(height: 620.h), + actions: [ + [ + FButton( + style: FButtonStyle.outline, + onPress: () => Navigator.of(context).pop(), + label: const Text('取消'), + ).expanded(), + FButton( + onPress: _save, + prefix: FIcon(FAssets.icons.save, size: 18.sp), + label: const Text('应用规则'), + ).expanded(), + ].toRowSpace(space: 12.w), + ], + ); + } + + /// 再次校验输入并关闭弹窗;非法表达式保留弹窗并展示错误。 + void _save() { + final expression = _controller.text.trim(); + if (!SyncCheckTool.isValidCron(expression)) { + setState(() => _error = '请输入有效的 6 段 Cron 表达式'); + return; + } + Navigator.of(context).pop(expression); + } +} + +/// Cron 预设分组的单选按钮。 +class _GroupButton extends StatelessWidget { + /// 创建一个由父状态控制选中态的预设分组按钮。 + const _GroupButton({ + required this.label, + required this.selected, + required this.onTap, + }); + + /// 分组的中文展示名称。 + final String label; + + /// 当前分组是否被选中。 + final bool selected; + + /// 点击后由父弹窗切换分组的回调。 + final VoidCallback onTap; + + /// 根据选中态构建固定高度按钮,避免切换分组时布局抖动。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Text( + label, + textAlign: TextAlign.center, + style: theme.typography.base.copyWith( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ).center().constrained(minHeight: 48.h).decorated( + color: selected + ? XfjDesignTokens.primarySoft + : XfjDesignTokens.surface, + border: Border.all( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + ); + } +} + +/// 单条常用 Cron 预设按钮,同时展示中文含义和实际表达式。 +class _PresetButton extends StatelessWidget { + /// 创建一个由父弹窗控制选中态和应用行为的预设按钮。 + const _PresetButton({ + required this.preset, + required this.selected, + required this.onTap, + }); + + /// 当前按钮代表的预设分组、名称和六段表达式。 + final ScheduledCronPreset preset; + + /// 输入框内容是否与该预设表达式完全一致。 + final bool selected; + + /// 点击后把该预设写入父弹窗输入框的回调。 + final VoidCallback onTap; + + /// 构建带最小触控高度的预设项,内容可随字体缩放增高以避免文字溢出。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: [ + Text( + preset.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 3.h), + Text( + preset.expression, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ] + .toColumn(crossAxisAlignment: CrossAxisAlignment.center) + .center() + .padding(horizontal: 10.w, vertical: 8.h) + .constrained(minHeight: 70.h) + .decorated( + color: selected + ? XfjDesignTokens.primarySoft + : XfjDesignTokens.surface, + border: Border.all( + color: selected ? XfjDesignTokens.primary : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + ); + } +} + +/// 将预设分组枚举转换为弹窗分段按钮使用的中文名称。 +String _groupLabel(ScheduledCronPresetGroup group) => switch (group) { + ScheduledCronPresetGroup.interval => '按间隔', + ScheduledCronPresetGroup.daily => '每天', + ScheduledCronPresetGroup.weekly => '每周', + ScheduledCronPresetGroup.monthly => '每月', + }; diff --git a/lib/pages/setting/widgets/setting_face.dart b/lib/pages/setting/widgets/setting_face.dart index 74e2ed3..42abd06 100644 --- a/lib/pages/setting/widgets/setting_face.dart +++ b/lib/pages/setting/widgets/setting_face.dart @@ -9,43 +9,112 @@ import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; -class FaceManageController extends GetxController { - static const String _tag = "人脸管理 Controller"; +import 'setting_components.dart'; +/// 人脸管理内容区的两个一级视图。 +enum FaceManageTab { + /// 全部本地人脸及既有同步维护能力。 + all, + + /// 已检测且分数低于当前在库阈值的人脸。 + lowQuality, +} + +/// 设置页人脸管理控制器,只负责列表交互和将人工同步请求提交到应用级队列。 +class FaceManageController extends GetxController { + /// 应用级数据库和事件总线持有者,由根 Binding 统一注册。 final appC = Get.find(); + /// 当前终端设置,初始化后用于确定系统类型和设备绑定状态。 late SysSetting sysSetting; + /// 当前终端业务类型,决定搜索框允许的输入方式。 Rx sysType = SysType.CANTEEN.obs; + /// 当前分页展示的人脸本地记录。 RxList faceList = RxList(); + /// 当前列表页码,从 1 开始。 RxInt currentPage = RxInt(1); + /// 当前搜索条件命中的记录总数,供分页组件计算页数。 RxInt totalPage = RxInt(0); + /// 单页展示数量,沿用现有人脸管理网格密度。 RxInt pageSize = RxInt(15); + /// 列表查询是否进行中,仅驱动列表区域加载状态。 + RxBool isLoading = false.obs; + + /// 最近一次列表查询失败的安全提示,不包含数据库或文件路径。 + RxString errorMessage = ''.obs; + + /// 质量检测或服务端删除的可恢复提示,不替代列表内容。 + final qualityOperationMessage = ''.obs; + + /// 当前选中的内容 Tab。 + final selectedTab = FaceManageTab.all.obs; + + /// Android 人脸设置中的在库质量阈值。 + final libraryQualityThreshold = 0.60.obs; + + /// 低质量、待检测和检测失败的实时统计。 + final lowQualityCount = 0.obs; + final pendingQualityCount = 0.obs; + final failedQualityCount = 0.obs; + + /// 存量质量检测的最新原生进度。 + final qualityAuditProgress = Rxn(); + + /// 同步、检测和服务端删除状态,用于互斥及按钮禁用。 + final isSyncing = false.obs; + final isAuditing = false.obs; + final isDeleting = false.obs; + + /// 姓名搜索输入控制器,由本控制器负责释放。 final usernameC = TextEditingController(); - late StreamSubscription mqUpdateListen; + /// FACE 增量任务成功后的列表刷新订阅,由 [onClose] 取消。 + StreamSubscription? mqUpdateListen; + /// 原生质量检测进度订阅,由 [onClose] 释放。 + StreamSubscription? qualityAuditListen; + + /// 启动异步设置读取、首次列表查询和 FACE 完成事件监听。 @override - void onInit() async { + void onInit() { super.onInit(); + unawaited(_initialize()); + } + + /// 读取终端设置后加载列表;初始化失败交由列表状态统一展示。 + Future _initialize() async { sysSetting = await AppSettingTool.getSysSetting(); sysType.value = sysSetting.sysType; - getFaceList(); _listenMqCallback(); + qualityAuditListen = FaceDiscernPlugs.qualityAuditStream.listen( + _handleQualityAuditProgress, + onError: (Object error, StackTrace stack) { + logger.w('|人脸管理|质量进度监听失败error:$error|stack:$stack|'); + }, + ); + await refreshQualityThreshold(); + await getFaceList(); } + /// 释放 MQTT 完成事件和输入控制器,防止退出设置页后继续刷新。 @override void onClose() { - mqUpdateListen.cancel(); + mqUpdateListen?.cancel(); + qualityAuditListen?.cancel(); usernameC.dispose(); + super.onClose(); } + /// 按当前姓名和页码读取本地人脸列表,可选显示旧页面使用的查询遮罩。 Future getFaceList({bool isLoading = false}) async { + this.isLoading.value = true; + errorMessage.value = ''; if (isLoading) { EasyLoading.show( status: '查询中...', @@ -56,15 +125,33 @@ class FaceManageController extends GetxController { final faceDao = appC.dataBase.getFaceDao; String userName = usernameC.text; if (ObjectUtil.isEmpty(userName)) userName = "-1"; - final findCount = await faceDao.findCount(userName); + final lowQuality = selectedTab.value == FaceManageTab.lowQuality; + final findCount = lowQuality + ? await faceDao.findLowQualityCount( + userName, + libraryQualityThreshold.value, + ) + : await faceDao.findCount(userName); totalPage.value = findCount ?? 0; - final list = await faceDao.findAll( - userName, - pageSize.value, - (currentPage.value - 1) * pageSize.value, - ); + final list = lowQuality + ? await faceDao.findLowQuality( + userName, + libraryQualityThreshold.value, + pageSize.value, + (currentPage.value - 1) * pageSize.value, + ) + : await faceDao.findAll( + userName, + pageSize.value, + (currentPage.value - 1) * pageSize.value, + ); faceList.value = list; + await _refreshQualityCounts(faceDao); + } catch (e, stack) { + errorMessage.value = '人脸数据加载失败,请稍后重试'; + logger.e('|人脸管理|加载失败error:|$e|stack:|$stack|'); } finally { + this.isLoading.value = false; if (isLoading) { Future.delayed(const Duration(seconds: 1), () { EasyLoading.dismiss(); @@ -73,16 +160,23 @@ class FaceManageController extends GetxController { } } - // 同步人脸 + /// 确认后将本部门快照或非生产全租户调试请求提交到统一人脸任务队列。 + /// + /// 队列会先避开支付、人脸会话和业务弹框;只有任务已经实际开始时才显示遮罩, + /// 避免遮罩本身被空闲检测识别为业务占用而导致任务无法启动。 Future asyncFace({bool isAll = false}) async { + if (operationInProgress) return; final isNetwork = AppNetworkTool.isAutoNetworkMsg(); if (!isNetwork) return; final setting = await AppSettingTool.getSysSetting(); - final serviceMerchantId = setting.serviceMerchantId; - if (ObjectUtil.isEmpty(serviceMerchantId)) { + if (ObjectUtil.isEmpty(setting.merchantDeptId)) { Toast.error(msg: "请先绑定设备"); return; } + if (isAll && AppSysTool.isProd()) { + Toast.error(msg: '生产环境仅允许同步本部门人脸'); + return; + } if (AppSysTool.isProd()) { final isActive = await AppFaceTool.isActiveSdk(); if (!isActive) { @@ -92,30 +186,211 @@ class FaceManageController extends GetxController { } final isSure = await appMessageDialog( Get.context!, - "是否同步所有人脸,这个过程可能比较长", + isAll ? '是否同步全租户人脸?此能力仅用于非生产调试,过程可能较长' : '是否同步本部门及子部门人脸?过程可能较长', ); if (!isSure) return; - EasyLoading.show( - status: '同步中...', - maskType: EasyLoadingMaskType.black, + final scheduler = SyncCheckTool.instance; + isSyncing.value = true; + final operation = scheduler.trigger( + ScheduledTaskType.departmentFaceSync, + debugAllFaces: isAll, + triggerType: ScheduledTaskTriggerType.manual, ); - try { - await SyncFaceTool.delEmptyFace(); - await SyncFaceTool.syncFace( - isAll: isAll, - onSyncGroup: () { - currentPage.value = 1; - getFaceList(); - }, + await Future.delayed(Duration.zero); + var overlayVisible = false; + if (scheduler.runningTasks.contains(ScheduledTaskType.departmentFaceSync)) { + overlayVisible = true; + EasyLoading.show( + status: '同步中...', + maskType: EasyLoadingMaskType.black, ); - getFaceList(); + } + try { + await operation; + if (scheduler.pendingTasks + .contains(ScheduledTaskType.departmentFaceSync)) { + Toast.success(msg: '人脸同步已排队,将在业务空闲后执行'); + return; + } + currentPage.value = 1; + await getFaceList(); + final record = scheduler.records[ScheduledTaskType.departmentFaceSync]; + if (record?.success == true) { + Toast.success(msg: record?.message ?? '人脸同步完成'); + } else { + Toast.error(msg: record?.message ?? '人脸同步失败,请稍后重试'); + } + } catch (e, stack) { + Toast.error(msg: '同步人脸失败,请稍后重试'); + logger.e('|人脸管理|同步失败error:|$e|stack:|$stack|'); } finally { currentPage.value = 1; - Toast.success(msg: "同步成功"); - EasyLoading.dismiss(); + isSyncing.value = false; + if (overlayVisible) EasyLoading.dismiss(); } } + /// 重新读取 Android 人脸配置并按新阈值立即重查低质量分类。 + Future refreshQualityThreshold() async { + try { + final configuration = await FaceDiscernPlugs.getRecognizeConfiguration(); + libraryQualityThreshold.value = + configuration.libraryFaceQualityThreshold.clamp(0.0, 1.0).toDouble(); + } catch (error, stack) { + logger.w('|人脸管理|读取在库质量阈值失败error:$error|stack:$stack|'); + } + } + + /// 切换全部或低质量视图,保留搜索词但从第一页按对应查询重新加载。 + Future switchTab(FaceManageTab tab) async { + if (selectedTab.value == tab) return; + selectedTab.value = tab; + currentPage.value = 1; + if (tab == FaceManageTab.lowQuality) { + await refreshQualityThreshold(); + } + await getFaceList(); + } + + /// 手动检测待检测及失败记录,检测失败不会移除人脸或特征。 + Future auditLibraryFaces({bool failedOnly = false}) async { + if (operationInProgress) return; + if (failedOnly && failedQualityCount.value == 0) return; + if (!failedOnly && + pendingQualityCount.value == 0 && + failedQualityCount.value == 0) { + Toast.success(msg: '没有需要检测的人脸'); + return; + } + isAuditing.value = true; + qualityOperationMessage.value = ''; + try { + final result = await SyncFaceTool.auditLibraryFaceQuality( + failedOnly: failedOnly, + ); + final success = result.results + .where((item) => item.status == FaceQualityStatus.detected) + .length; + final failed = result.results.length - success; + if (failed > 0) { + qualityOperationMessage.value = + '检测完成:成功 $success 条,失败 $failed 条,可点击失败重试'; + Toast.error(msg: qualityOperationMessage.value); + } else { + Toast.success(msg: '质量检测完成,共 ${result.results.length} 条'); + } + } catch (error, stack) { + qualityOperationMessage.value = '在库人脸质量检测失败,请稍后重试'; + logger.e('|人脸管理|质量检测失败error:$error|stack:$stack|'); + Toast.error(msg: qualityOperationMessage.value); + } finally { + isAuditing.value = false; + currentPage.value = 1; + await getFaceList(); + } + } + + /// 确认后删除一条低质量人脸,服务端成功或已不存在后才清理本地。 + Future deleteLowQualityFace(FaceEntity face) async { + if (operationInProgress) return; + final confirmed = await appMessageDialog( + Get.context!, + '是否删除“${face.userName}”的该低质量人脸?删除后将不再参与识别。', + ); + if (!confirmed) return; + await _deleteServerBackedFaces([face]); + } + + /// 删除当前阈值下全部明确低质量的人脸,不受搜索词和分页影响。 + Future deleteAllLowQualityFaces() async { + if (operationInProgress || lowQualityCount.value == 0) return; + final faces = await appC.dataBase.getFaceDao.findAllLowQuality( + libraryQualityThreshold.value, + ); + if (faces.isEmpty) { + await getFaceList(); + return; + } + final confirmed = await appMessageDialog( + Get.context!, + '是否删除全部 ${faces.length} 条低质量人脸?该操作会先删除服务端数据,且不可撤销。', + ); + if (!confirmed) return; + await _deleteServerBackedFaces(faces); + } + + /// 按服务端 500 条上限分批删除,并仅清理服务端已确认的本地样本。 + Future _deleteServerBackedFaces(List faces) async { + isDeleting.value = true; + qualityOperationMessage.value = ''; + var localDeleted = 0; + var localFailed = 0; + try { + for (var start = 0; start < faces.length; start += 500) { + final end = (start + 500).clamp(0, faces.length).toInt(); + final batch = faces.sublist(start, end); + final response = await UserApi.deleteTerminalFaces( + batch.map((face) => face.sampleId).toList(growable: false), + ); + final confirmed = response.confirmedSampleIds; + for (final face in batch) { + if (!confirmed.contains(face.sampleId)) { + throw StateError('服务端未确认样本删除结果'); + } + if (await SyncFaceTool.deleteLocalFace(face)) { + localDeleted++; + } else { + localFailed++; + } + } + } + if (localFailed > 0) { + qualityOperationMessage.value = + '服务端已删除 ${localDeleted + localFailed} 条,本地清理失败 $localFailed 条,可再次删除重试'; + Toast.error(msg: qualityOperationMessage.value); + } else { + Toast.success(msg: '已删除 $localDeleted 条低质量人脸'); + } + } catch (error, stack) { + qualityOperationMessage.value = localDeleted > 0 + ? '已完成 $localDeleted 条,后续批次删除失败;未确认样本已保留在本机' + : '服务端删除失败,本机人脸已保留'; + logger.e('|人脸管理|服务端删除失败error:$error|stack:$stack|'); + Toast.error(msg: qualityOperationMessage.value); + } finally { + isDeleting.value = false; + currentPage.value = 1; + await getFaceList(); + } + } + + /// 刷新不受当前搜索条件影响的质量摘要。 + Future _refreshQualityCounts(FaceDao faceDao) async { + lowQualityCount.value = await faceDao.findLowQualityCount( + '-1', libraryQualityThreshold.value) ?? + 0; + pendingQualityCount.value = await faceDao.findPendingQualityCount() ?? 0; + failedQualityCount.value = await faceDao.findFailedQualityCount() ?? 0; + } + + /// 将原生进度同步到页面;完成事件不负责数据库刷新,最终结果返回后统一收敛。 + void _handleQualityAuditProgress(FaceQualityAuditProgress value) { + qualityAuditProgress.value = value; + if (value.inProgress || value.processed > 0) { + isAuditing.value = value.inProgress; + } + } + + /// 同步、检测和删除共同占用人脸维护区,防止并发操作造成状态交叉。 + bool get operationInProgress => + isSyncing.value || + isAuditing.value || + isDeleting.value || + SyncCheckTool.instance.runningTasks.contains( + ScheduledTaskType.departmentFaceSync, + ); + + /// 经二次确认后清理本机全部人脸记录和虹软内存特征,仅影响本机数据。 Future delAllFace() async { final isSure = await appMessageDialog( Get.context!, @@ -129,29 +404,31 @@ class FaceManageController extends GetxController { try { final faceDao = appC.dataBase.getFaceDao; await faceDao.delAll(); - await AppFaceTool.removeFace(sampleId: "-1"); + await AppFaceTool.removeFace(sampleId: '-1'); + await getFaceList(); + Toast.success(msg: '删除成功'); + } catch (e, stack) { + Toast.error(msg: '删除全部人脸失败'); + logger.e('|人脸管理|删除全部失败error:|$e|stack:|$stack|'); } finally { - Future.delayed(const Duration(seconds: 1), () { - getFaceList(); - Toast.success(msg: "同步成功"); - EasyLoading.dismiss(); - }); + EasyLoading.dismiss(); } } - _listenMqCallback() { + /// 监听成功完成的 FACE 增量同步,并仅刷新当前设置页本地列表。 + void _listenMqCallback() { mqUpdateListen = appC.mqUpdateEventBus.on().listen((event) async { switch (event) { case MqtActionEnum.FACE: - getFaceList(isLoading: true); + await getFaceList(isLoading: true); break; default: } }); } - /// 搜索 + /// 按指定页码执行姓名搜索,并在查询前收起软键盘。 Future doPage({int? page}) async { if (page != null) currentPage.value = page; // 收起键盘 @@ -159,7 +436,7 @@ class FaceManageController extends GetxController { await getFaceList(isLoading: true); } - /// 重置 + /// 清空姓名条件、回到第一页并重新加载列表。 Future doReset() async { currentPage.value = 1; usernameC.clear(); @@ -168,7 +445,7 @@ class FaceManageController extends GetxController { await getFaceList(isLoading: true); } - // 删除人脸 + /// 删除一条本地人脸及其虹软内存特征,不修改服务端人脸真源。 Future deleteFace(FaceEntity v) async { final isSure = await appMessageDialog(Get.context!, "是否删除人脸"); if (!isSure) return; @@ -177,167 +454,727 @@ class FaceManageController extends GetxController { maskType: EasyLoadingMaskType.black, ); try { - final faceDao = appC.dataBase.getFaceDao; - if (ObjectUtil.isNotEmpty(v.path)) { - await SyncFaceTool.delFaceByStorage(filePath: v.path!); - } - await faceDao.delById(v.id!); - await AppFaceTool.removeFace(sampleId: v.sampleId); + final deleted = await SyncFaceTool.deleteLocalFace(v); + if (!deleted) throw StateError('本地人脸删除失败并已尝试恢复'); + await getFaceList(); + Toast.success(msg: '删除成功'); + } catch (e, stack) { + Toast.error(msg: '删除人脸失败'); + logger.e('|人脸管理|删除失败error:|$e|stack:|$stack|'); } finally { - Future.delayed(const Duration(seconds: 1), () { - getFaceList(); - Toast.success(msg: "删除成功"); - EasyLoading.dismiss(); - }); + EasyLoading.dismiss(); } } } -/// 搜索 -Widget buildFaceSearch(BuildContext context) { - final faceC = Get.find(); - final theme = FTheme.of(context); - return [ - const Spacer(), - Obx( - () => FTextField( - textInputAction: faceC.sysType.value == SysType.SUPERMARKET - ? TextInputAction.none - : TextInputAction.newline, - keyboardType: faceC.sysType.value == SysType.SUPERMARKET - ? TextInputType.none - : TextInputType.multiline, - controller: faceC.usernameC, - hint: '请输入姓名', - maxLines: 1, - onSubmit: (_) => faceC.doPage(), - ), - ).width(300.w), - FButton( - prefix: FIcon( - FAssets.icons.ban, - size: theme.typography.base.fontSize, - ), - label: Text( - '重置', - style: theme.typography.base.copyWith( - color: theme.colorScheme.secondary, - ), - ), - onPress: () => faceC.doReset(), - ), - FButton( - prefix: FIcon( - FAssets.icons.search, - size: theme.typography.base.fontSize, - ), - label: Text( - '搜索', - style: theme.typography.base.copyWith( - color: theme.colorScheme.secondary, - ), - ), - onPress: () => faceC.doPage(), - ), - FButton( - label: Text( - '删除全部人脸', - style: theme.typography.base.copyWith( - color: theme.colorScheme.secondary, - ), - ), - style: FButtonStyle.destructive, - onPress: () => faceC.delAllFace(), - ), - FButton( - label: Text( - '同步本部门人脸', - style: theme.typography.base.copyWith( - color: theme.colorScheme.primary, - ), - ), - style: FButtonStyle.outline, - onPress: () => faceC.asyncFace(isAll: false), - ), - FButton( - label: Text( - '同步全部人脸', - style: theme.typography.base.copyWith( - color: theme.colorScheme.primary, - ), - ), - style: FButtonStyle.outline, - onPress: () => faceC.asyncFace(isAll: true), - ), - ].toRowSpace(space: 20.w); +/// 构建紧凑的人脸视图切换栏,并在低质量页同行展示质量摘要。 +Widget _buildFaceHeader(BuildContext context) { + final controller = Get.find(); + return Obx( + () { + final lowQuality = + controller.selectedTab.value == FaceManageTab.lowQuality; + return Row( + children: [ + FaceManageTabBar( + selectedTab: controller.selectedTab.value, + onChanged: controller.switchTab, + ), + if (lowQuality) ...[ + SizedBox(width: 12.w), + SizedBox( + width: 640.w, + child: _buildQualitySummary(controller), + ), + SizedBox(width: 12.w), + Expanded(child: _buildQualityDiagnosticHint(context)), + ], + ], + ); + }, + ); } -/// 列表 -Widget buildFaceList(context) { - final theme = FTheme.of(context); - final productC = Get.find(); - return Obx( - () => GridView.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 5, - mainAxisSpacing: 10.w, - crossAxisSpacing: 10.w, - childAspectRatio: 1.2, - ), - itemCount: productC.faceList.length, - itemBuilder: (BuildContext context, int index) { - final v = productC.faceList[index]; - return FCard( - style: theme.cardStyle.copyWith( - contentStyle: theme.cardStyle.contentStyle.copyWith( - padding: EdgeInsets.all(0), +/// 人脸管理内容区的可测试文字 Tab,仅负责选中展示和点击回调。 +class FaceManageTabBar extends StatelessWidget { + /// 创建一组固定高度且满足大屏触控尺寸的内容 Tab。 + const FaceManageTabBar({ + super.key, + required this.selectedTab, + required this.onChanged, + }); + + /// 当前选中的业务视图。 + final FaceManageTab selectedTab; + + /// 用户点击不同 Tab 时的回调;异步列表刷新由控制器负责。 + final ValueChanged onChanged; + + /// 按当前 ForUI 主题和终端设计令牌构建选中态、按压态及无障碍语义。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return SizedBox( + height: 48, + width: 320.w, + child: Stack( + alignment: Alignment.center, + children: [ + Container( + height: 36, + decoration: BoxDecoration( + color: XfjDesignTokens.control, + borderRadius: + BorderRadius.circular(XfjDesignTokens.controlRadius), ), ), - image: appImg(v.path, width: 120.w, height: 120.w) - .center() - .marginOnly(top: 5.h), - title: Text( - v.userName, - maxLines: 1, - style: theme.typography.sm, - ).center(), - subtitle: [ - Text( - "用户编号:${v.userId}", - style: theme.typography.xs.copyWith(height: 1), + Row( + children: FaceManageTab.values.map((tab) { + final selected = selectedTab == tab; + final label = tab == FaceManageTab.all ? '人脸管理' : '低质量人脸'; + return Expanded( + child: Semantics( + button: true, + selected: selected, + excludeSemantics: true, + label: '$label Tab${selected ? ',已选中' : ''}', + child: InkWell( + onTap: () => onChanged(tab), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: Center( + child: Container( + height: 36, + width: double.infinity, + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected + ? XfjDesignTokens.surface + : Colors.transparent, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + boxShadow: + selected ? XfjDesignTokens.surfaceShadow : null, + ), + child: Text( + label, + style: theme.typography.sm.copyWith( + color: selected + ? XfjDesignTokens.primary + : XfjDesignTokens.muted, + fontWeight: + selected ? FontWeight.w700 : FontWeight.w600, + ), + ), + ), + ), + ), + ), + ); + }).toList(growable: false), + ), + ], + ), + ); + } +} + +/// 说明虹软质量分的能力边界,并给出可执行的照片排查方向。 +Widget _buildQualityDiagnosticHint(BuildContext context) { + final theme = FTheme.of(context); + const message = '虹软仅返回综合分;请排查清晰度、光照、角度和人脸大小'; + return Semantics( + container: true, + label: message, + child: Container( + height: 36, + padding: EdgeInsets.symmetric(horizontal: 8.w), + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + border: Border.all( + color: XfjDesignTokens.primary.withValues(alpha: 0.20), + ), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + FIcon(FAssets.icons.info, + color: XfjDesignTokens.primary, size: 14.sp), + SizedBox(width: 5.w), + Expanded( + child: Text( + message, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + ), ), - Text( - "样本编号:${v.sampleId}", - style: theme.typography.xs.copyWith(height: 1), - ), - ] - .toColumnSpace( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - ) - .width(double.infinity) - .marginOnly(top: 5.h), - ).onTap(() => productC.deleteFace(v)); - }, + ), + ], + ), ), ); } -Widget settingFace(BuildContext context) { - final faceC = Get.put( - FaceManageController(), - ); - return [ - buildFaceSearch(context), - buildFaceList(context).expanded(), - Obx( - () => appPage( - context, - pageNo: faceC.currentPage.value, - pageSize: faceC.pageSize.value, - total: faceC.totalPage.value, - onPageChanged: (pageNo) => faceC.doPage(page: pageNo), +/// 构建当前系统类型适配的姓名搜索框。 +Widget _buildFaceNameField(FaceManageController controller) => Obx( + () => TerminalToolbarTextField( + width: 300.w, + textInputAction: controller.sysType.value == SysType.SUPERMARKET + ? TextInputAction.none + : TextInputAction.newline, + keyboardType: controller.sysType.value == SysType.SUPERMARKET + ? TextInputType.none + : TextInputType.multiline, + controller: controller.usernameC, + hint: '请输入姓名', + onSubmit: (_) => controller.doPage(), ), - ), - ].toColumn(); + ); + +/// 构建人脸搜索与既有同步操作区;全租户同步仅在非生产调试环境出现。 +Widget buildFaceSearch(BuildContext _) { + final controller = Get.find(); + return Obx(() { + final busy = controller.operationInProgress; + return [ + _buildFaceNameField(controller), + TerminalToolbarButton( + label: '重置', + icon: FAssets.icons.ban, + style: TerminalToolbarButtonStyle.secondary, + onTap: busy ? null : controller.doReset, + ), + TerminalToolbarButton( + label: '搜索', + icon: FAssets.icons.search, + onTap: busy ? null : controller.doPage, + ), + TerminalToolbarButton( + label: controller.isSyncing.value ? '同步中...' : '同步本部门人脸', + icon: FAssets.icons.scanFace, + style: TerminalToolbarButtonStyle.secondary, + minWidth: 188.w, + onTap: busy ? null : () => controller.asyncFace(isAll: false), + ), + if (!AppSysTool.isProd()) + TerminalToolbarButton( + label: '同步全部人脸', + icon: FAssets.icons.scanFace, + style: TerminalToolbarButtonStyle.secondary, + minWidth: 176.w, + onTap: busy ? null : () => controller.asyncFace(isAll: true), + ), + const Spacer(), + TerminalToolbarButton( + label: '删除全部人脸', + icon: FAssets.icons.trash2, + style: TerminalToolbarButtonStyle.destructive, + minWidth: 176.w, + onTap: busy ? null : controller.delAllFace, + ), + ].toRowSpace(space: 12.w); + }); +} + +/// 构建低质量页的单行只读质量摘要。 +Widget _buildQualitySummary( + FaceManageController controller, +) { + return Container( + height: 36, + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + _QualityMetric( + label: '当前阈值', + value: controller.libraryQualityThreshold.value.toStringAsFixed(2), + icon: FAssets.icons.slidersHorizontal, + color: XfjDesignTokens.primary, + flex: 3, + ), + _QualityMetric( + label: '低质量', + value: '${controller.lowQualityCount.value}', + icon: FAssets.icons.triangleAlert, + color: XfjDesignTokens.danger, + ), + _QualityMetric( + label: '待检测', + value: '${controller.pendingQualityCount.value}', + icon: FAssets.icons.image, + color: XfjDesignTokens.warning, + ), + _QualityMetric( + label: '检测失败', + value: '${controller.failedQualityCount.value}', + icon: FAssets.icons.circleAlert, + color: XfjDesignTokens.danger, + showDivider: false, + ), + ], + ), + ); +} + +/// 构建低质量页搜索和互斥操作区。 +Widget _buildLowQualityOperations(BuildContext _) { + final controller = Get.find(); + return Obx(() { + final busy = controller.operationInProgress; + final pending = controller.pendingQualityCount.value; + final failed = controller.failedQualityCount.value; + final low = controller.lowQualityCount.value; + final progress = controller.qualityAuditProgress.value; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + _buildFaceNameField(controller), + SizedBox(width: 12.w), + TerminalToolbarButton( + label: '重置', + icon: FAssets.icons.ban, + style: TerminalToolbarButtonStyle.secondary, + onTap: busy ? null : controller.doReset, + ), + SizedBox(width: 12.w), + TerminalToolbarButton( + label: '搜索', + icon: FAssets.icons.search, + onTap: busy ? null : controller.doPage, + ), + const Spacer(), + TerminalToolbarButton( + label: controller.isAuditing.value ? '检测中...' : '检测存量人脸', + icon: FAssets.icons.scanFace, + style: TerminalToolbarButtonStyle.secondary, + minWidth: 176.w, + onTap: busy || (pending + failed == 0) + ? null + : controller.auditLibraryFaces, + ), + SizedBox(width: 12.w), + TerminalToolbarButton( + label: '失败重试', + icon: FAssets.icons.refreshCw, + style: TerminalToolbarButtonStyle.secondary, + minWidth: 140.w, + onTap: busy || failed == 0 + ? null + : () => controller.auditLibraryFaces(failedOnly: true), + ), + SizedBox(width: 12.w), + TerminalToolbarButton( + label: controller.isDeleting.value ? '删除中...' : '一键删除', + icon: FAssets.icons.trash2, + style: TerminalToolbarButtonStyle.destructive, + minWidth: 140.w, + onTap: + busy || low == 0 ? null : controller.deleteAllLowQualityFaces, + ), + ], + ), + if (progress != null && + (progress.inProgress || progress.total > 0)) ...[ + SizedBox(height: 8.h), + _QualityAuditProgress(progress: progress), + ], + if (controller.qualityOperationMessage.isNotEmpty) ...[ + SizedBox(height: 8.h), + Semantics( + liveRegion: true, + child: SettingNotice( + text: controller.qualityOperationMessage.value, + dense: true, + icon: FAssets.icons.triangleAlert, + color: XfjDesignTokens.danger, + background: XfjDesignTokens.danger.withValues(alpha: 0.05), + borderColor: XfjDesignTokens.danger.withValues(alpha: 0.24), + ), + ), + ], + ], + ); + }); +} + +/// 紧凑质量摘要指标,使用文字、图标和颜色共同表达状态。 +class _QualityMetric extends StatelessWidget { + const _QualityMetric({ + required this.label, + required this.value, + required this.icon, + required this.color, + this.flex = 2, + this.showDivider = true, + }); + + final String label; + final String value; + final SvgAsset icon; + final Color color; + final int flex; + final bool showDivider; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Expanded( + flex: flex, + child: Semantics( + container: true, + label: '$label $value', + excludeSemantics: true, + child: Container( + height: 34, + padding: EdgeInsets.symmetric(horizontal: 8.w), + decoration: BoxDecoration( + border: showDivider + ? const Border( + right: BorderSide(color: XfjDesignTokens.line), + ) + : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FIcon(icon, color: color, size: 14.sp), + SizedBox(width: 4.w), + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ), + SizedBox(width: 4.w), + Text( + value, + style: theme.typography.xs.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// 显示存量质量检测百分比和成功、失败数量。 +class _QualityAuditProgress extends StatelessWidget { + const _QualityAuditProgress({required this.progress}); + + final FaceQualityAuditProgress progress; + + @override + Widget build(BuildContext context) { + final percent = (progress.fraction * 100).round(); + return Semantics( + liveRegion: !progress.inProgress, + label: + '${progress.message},$percent%,已处理${progress.processed}/${progress.total},成功${progress.successCount},失败${progress.failedCount}', + child: Container( + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 10.h), + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(99), + child: LinearProgressIndicator( + minHeight: 8.h, + value: progress.total == 0 && progress.inProgress + ? null + : progress.fraction, + backgroundColor: XfjDesignTokens.control, + valueColor: const AlwaysStoppedAnimation( + XfjDesignTokens.primary, + ), + ), + ), + ), + SizedBox(width: 14.w), + Text('$percent% ${progress.processed}/${progress.total}'), + SizedBox(width: 14.w), + Text('成功 ${progress.successCount} 失败 ${progress.failedCount}'), + ], + ), + ), + ); + } +} + +/// 低质量人脸卡片的紧凑删除按钮。 +/// +/// 可见按钮固定为 32dp 高,避免 ForUI 主题的全局按钮尺寸撑破卡片;外层仍保留 +/// 48dp 的 Android 最小触控高度,并提供完整的删除语义和按压反馈。 +class _CompactFaceDeleteButton extends StatelessWidget { + const _CompactFaceDeleteButton({ + required this.face, + required this.onPressed, + }); + + final FaceEntity face; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final enabled = onPressed != null; + final borderRadius = BorderRadius.circular(XfjDesignTokens.controlRadius); + return Semantics( + key: ValueKey('low-quality-delete-hit-${face.sampleId}'), + container: true, + button: true, + enabled: enabled, + label: '删除${face.userName}的低质量人脸', + onTap: onPressed, + child: ExcludeSemantics( + child: SizedBox( + width: double.infinity, + height: 48, + child: Center( + child: SizedBox( + key: ValueKey('low-quality-delete-visual-${face.sampleId}'), + width: 116, + height: 32, + child: Material( + color: enabled + ? XfjDesignTokens.danger + : XfjDesignTokens.danger.withValues(alpha: 0.42), + borderRadius: borderRadius, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onPressed, + borderRadius: borderRadius, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FIcon( + FAssets.icons.trash2, + color: Colors.white, + size: 14.sp, + ), + SizedBox(width: 4.w), + Text( + '删除', + style: theme.typography.xs.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +/// 构建支持加载、失败、空数据和分页内容的人脸本地样本网格。 +Widget buildFaceList(BuildContext context) { + final theme = FTheme.of(context); + final controller = Get.find(); + return Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + if (controller.errorMessage.isNotEmpty) { + return SettingEmpty(message: controller.errorMessage.value); + } + final lowQuality = controller.selectedTab.value == FaceManageTab.lowQuality; + if (controller.faceList.isEmpty) { + return SettingEmpty( + message: lowQuality ? '当前阈值下暂无低质量人脸' : '暂无本地人脸样本', + ); + } + return GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + mainAxisSpacing: XfjDesignTokens.cardGap.h, + crossAxisSpacing: XfjDesignTokens.cardGap.w, + childAspectRatio: 1.2, + mainAxisExtent: lowQuality ? 296.h : null, + ), + itemCount: controller.faceList.length, + itemBuilder: (BuildContext context, int index) { + final face = controller.faceList[index]; + final content = Container( + padding: lowQuality + ? EdgeInsets.symmetric(horizontal: 10.w, vertical: 8.h) + : EdgeInsets.all(14.w), + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all( + color: lowQuality + ? XfjDesignTokens.danger.withValues(alpha: 0.35) + : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + boxShadow: XfjDesignTokens.surfaceShadow, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Semantics( + image: true, + label: '${face.userName}的人脸照片', + child: ClipOval( + child: appImg( + face.path, + width: (lowQuality ? 52 : 72).w, + height: (lowQuality ? 52 : 72).w, + ), + ), + ), + SizedBox(height: (lowQuality ? 4 : 8).h), + Text( + face.userName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: + (lowQuality ? theme.typography.sm : theme.typography.base) + .copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: (lowQuality ? 2 : 4).h), + Text( + '用户编号:${_maskFaceId(face.userId)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + height: lowQuality ? 1.1 : null, + ), + ), + SizedBox(height: (lowQuality ? 1 : 2).h), + Text( + '样本编号:${_maskFaceId(face.sampleId)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + height: lowQuality ? 1.1 : null, + ), + ), + if (lowQuality) ...[ + SizedBox(height: 4.h), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FIcon( + FAssets.icons.triangleAlert, + color: XfjDesignTokens.danger, + size: 14.sp, + ), + SizedBox(width: 4.w), + Text( + '低质量 ${face.qualityScore?.toStringAsFixed(2) ?? '-'}', + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.danger, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + SizedBox(height: 2.h), + Text( + '原因:评分低于 ${controller.libraryQualityThreshold.value.toStringAsFixed(2)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + height: 1.1, + ), + ), + SizedBox(height: 4.h), + _CompactFaceDeleteButton( + face: face, + onPressed: controller.operationInProgress + ? null + : () => controller.deleteLowQualityFace(face), + ), + ], + ], + ), + ); + if (lowQuality) return content; + return Semantics( + button: true, + label: '删除${face.userName}的本地人脸', + child: InkWell( + onTap: controller.operationInProgress + ? null + : () => controller.deleteFace(face), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + child: content, + ), + ); + }, + ); + }); +} + +/// 构建带两个内容 Tab 的人脸管理页面。 +Widget settingFace(BuildContext context) { + final controller = Get.find(); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildFaceHeader(context), + Expanded( + child: Obx(() { + final lowQuality = + controller.selectedTab.value == FaceManageTab.lowQuality; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (lowQuality) + _buildLowQualityOperations(context) + else + buildFaceSearch(context), + SizedBox(height: XfjDesignTokens.cardGap.h), + Expanded(child: buildFaceList(context)), + SizedBox(height: XfjDesignTokens.cardGap.h), + appPage( + context, + pageNo: controller.currentPage.value, + pageSize: controller.pageSize.value, + total: controller.totalPage.value, + onPageChanged: (pageNo) => controller.doPage(page: pageNo), + ), + ], + ); + }), + ), + ], + ); +} + +String _maskFaceId(String value) { + if (value.length <= 4) return '***$value'; + return '${value.substring(0, 1)}***${value.substring(value.length - 3)}'; } diff --git a/lib/pages/setting/widgets/setting_face_plugin.dart b/lib/pages/setting/widgets/setting_face_plugin.dart new file mode 100644 index 0000000..8dc191d --- /dev/null +++ b/lib/pages/setting/widgets/setting_face_plugin.dart @@ -0,0 +1,956 @@ +import 'package:ducafe_ui_core/ducafe_ui_core.dart'; +import 'package:flutter/material.dart'; +import 'package:forui/forui.dart'; +import 'package:get/get.dart'; + +import 'package:pj_supermarket_cloud/common/index.dart'; +import '../face_setting_controller.dart'; + +import 'setting_components.dart'; + +Widget settingFacePlugin(BuildContext context) { + final controller = Get.find(); + return Obx(() { + if (controller.isLoading.value && controller.status.value == null) { + return const Center(child: CircularProgressIndicator()); + } + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _FaceStatusGroup(controller: controller), + SizedBox(height: XfjDesignTokens.cardGap.h), + _RecognitionConfigurationGroup(controller: controller), + SizedBox(height: XfjDesignTokens.cardGap.h), + _ActivationParameterGroup(controller: controller), + if ((controller.progress.value != null && + controller.progress.value!.phase != 'idle') || + controller.lastResult.value != null) ...[ + SizedBox(height: XfjDesignTokens.cardGap.h), + _RegistrationProgressCard(controller: controller), + ], + if (controller.errorMessage.isNotEmpty || + (controller.lastResult.value?.failedCount ?? 0) > 0) ...[ + SizedBox(height: XfjDesignTokens.cardGap.h), + _FaceRecoveryCard(controller: controller), + ], + ], + ), + ); + }); +} + +/// 现场只开放能明确解释、且原生已做范围校验的识别策略;保存不会打断正在支付的顾客。 +class _RecognitionConfigurationGroup extends StatelessWidget { + const _RecognitionConfigurationGroup({required this.controller}); + + final FaceSettingController controller; + + @override + Widget build(BuildContext context) { + // 子组件的 build 晚于外层 Obx 执行,必须在这里读取 Rx,才能让开关、滑块和展开项即时刷新。 + return Obx(() { + final configuration = controller.recognitionConfiguration.value; + if (configuration == null) { + return SettingGroup( + title: '识别策略', + child: SizedBox( + height: 100.h, + child: controller.isLoadingRecognitionConfiguration.value + ? const Center(child: CircularProgressIndicator()) + : SettingInfoRow( + icon: FAssets.icons.triangleAlert, + label: '配置读取失败', + subtitle: '未能从 Android 人脸插件读取识别参数', + actionLabel: '重新读取', + onTap: controller.refreshRecognitionConfiguration, + showDivider: false, + ), + ), + ); + } + + final saving = controller.isSavingRecognitionConfiguration.value; + final changed = controller.recognitionConfigurationChanged; + return SettingGroup( + title: '识别策略', + child: Column( + children: [ + SettingInfoRow( + icon: configuration.enableLiveness + ? FAssets.icons.shieldCheck + : FAssets.icons.shieldOff, + label: 'RGB 活体认证', + subtitle: '防止照片或屏幕翻拍;当前单摄像头方案仅支持 RGB 活体', + value: configuration.enableLiveness ? '已开启' : '已关闭', + trailing: FSwitch( + semanticLabel: '开启 RGB 活体认证', + value: configuration.enableLiveness, + enabled: !saving, + onChange: (value) => controller.updateRecognitionConfiguration( + (current) => current.copyWith(enableLiveness: value), + ), + ), + ), + _RecognitionValueRow( + icon: FAssets.icons.scanFace, + label: '识别成功相似度', + subtitle: '候选人员相似度高于该值才算识别成功;阈值越高越严格', + value: configuration.similarThreshold, + min: 0.5, + max: 0.99, + step: 0.01, + formatter: _percent, + enabled: !saving, + onChanged: (value) => controller.updateRecognitionConfiguration( + (current) => current.copyWith(similarThreshold: value), + ), + ), + _RecognitionValueRow( + icon: FAssets.icons.activity, + label: 'RGB 活体阈值', + subtitle: configuration.enableLiveness + ? '活体分数达到该值才继续进行人脸比对;阈值越高越严格' + : '开启活体认证后生效', + value: configuration.rgbLivenessThreshold, + min: 0.3, + max: 0.9, + step: 0.01, + formatter: _percent, + enabled: configuration.enableLiveness && !saving, + onChanged: (value) => controller.updateRecognitionConfiguration( + (current) => current.copyWith(rgbLivenessThreshold: value), + ), + ), + SettingInfoRow( + icon: FAssets.icons.image, + label: '图像质量检测', + subtitle: '过滤过暗、模糊或质量不足的人脸,降低误识别风险', + value: configuration.enableImageQuality ? '已开启' : '已关闭', + trailing: FSwitch( + semanticLabel: '开启人脸图像质量检测', + value: configuration.enableImageQuality, + enabled: !saving, + onChange: (value) => controller.updateRecognitionConfiguration( + (current) => current.copyWith(enableImageQuality: value), + ), + ), + ), + _RecognitionValueRow( + icon: FAssets.icons.image, + label: '在库人脸质量阈值', + subtitle: '仅用于低质量人脸诊断;低于该分数会出现在低质量 Tab,仍继续参与识别', + value: configuration.libraryFaceQualityThreshold, + min: 0, + max: 1, + step: 0.01, + formatter: _decimal, + enabled: !saving, + onChanged: (value) => controller.updateRecognitionConfiguration( + (current) => current.copyWith( + libraryFaceQualityThreshold: value, + ), + ), + ), + SettingInfoRow( + icon: configuration.keepMaxFace + ? FAssets.icons.userRoundCheck + : FAssets.icons.users, + label: '仅识别最大人脸', + subtitle: '开启后最多同时标出 3 张人脸,仅面积最大的绿色目标框参与识别', + value: configuration.keepMaxFace ? '已开启' : '已关闭', + trailing: FSwitch( + semanticLabel: '仅识别画面中最大人脸', + value: configuration.keepMaxFace, + enabled: !saving, + onChange: (value) => controller.updateRecognitionConfiguration( + (current) => current.copyWith(keepMaxFace: value), + ), + ), + ), + SettingInfoRow( + icon: FAssets.icons.slidersHorizontal, + label: '高级参数', + subtitle: '图像质量阈值、重试次数和失败重试间隔', + value: controller.showAdvancedRecognitionConfiguration.value + ? '已展开' + : '已收起', + actionLabel: controller.showAdvancedRecognitionConfiguration.value + ? '收起' + : '展开', + onTap: controller.toggleAdvancedRecognitionConfiguration, + showDivider: + !controller.showAdvancedRecognitionConfiguration.value, + ), + if (controller.showAdvancedRecognitionConfiguration.value) ...[ + _RecognitionValueRow( + icon: FAssets.icons.userRound, + label: '无口罩图像质量阈值', + subtitle: '未佩戴口罩时,图像质量低于该值将继续等待更清晰画面', + value: configuration.imageQualityNoMaskRecognizeThreshold, + min: 0, + max: 1, + step: 0.01, + formatter: _decimal, + enabled: configuration.enableImageQuality && !saving, + onChanged: (value) => controller.updateRecognitionConfiguration( + (current) => current.copyWith( + imageQualityNoMaskRecognizeThreshold: value, + ), + ), + ), + _RecognitionValueRow( + icon: FAssets.icons.userRound, + label: '戴口罩图像质量阈值', + subtitle: '佩戴口罩时使用独立阈值,避免沿用无口罩标准导致持续等待', + value: configuration.imageQualityMaskRecognizeThreshold, + min: 0, + max: 1, + step: 0.01, + formatter: _decimal, + enabled: configuration.enableImageQuality && !saving, + onChanged: (value) => controller.updateRecognitionConfiguration( + (current) => current.copyWith( + imageQualityMaskRecognizeThreshold: value, + ), + ), + ), + _RecognitionIntegerSelector( + icon: FAssets.icons.scanFace, + label: '最大检测人脸数', + subtitle: configuration.keepMaxFace + ? '仅识别最大人脸开启时固定检测最多 3 张;关闭后按此数量参与识别' + : '多人识别时单帧最多跟踪并参与识别的人脸数量', + value: configuration.maxDetectFaces, + options: const [1, 2, 3], + enabled: !configuration.keepMaxFace && !saving, + onChanged: (value) => controller.updateRecognitionConfiguration( + (current) => current.copyWith(maxDetectFaces: value), + ), + ), + _RecognitionIntegerSelector( + icon: FAssets.icons.rotateCcw, + label: '特征提取重试次数', + subtitle: '提取失败后允许继续尝试的次数', + value: configuration.extractRetryCount, + options: const [1, 2, 3, 5, 8], + enabled: !saving, + onChanged: (value) => controller.updateRecognitionConfiguration( + (current) => current.copyWith(extractRetryCount: value), + ), + ), + _RecognitionIntegerSelector( + icon: FAssets.icons.shieldCheck, + label: '活体失败重试次数', + subtitle: '活体引擎连续报错达到该次数后重置检测状态', + value: configuration.livenessRetryCount, + options: const [1, 2, 3, 5, 8], + enabled: configuration.enableLiveness && !saving, + onChanged: (value) => controller.updateRecognitionConfiguration( + (current) => current.copyWith(livenessRetryCount: value), + ), + ), + _RecognitionValueRow( + icon: FAssets.icons.timerReset, + label: '识别失败重试间隔', + subtitle: '未注册或相似度不足后,等待多久再次尝试', + value: configuration.recognizeFailedRetryInterval.toDouble(), + min: 0, + max: 5000, + step: 100, + formatter: _milliseconds, + enabled: !saving, + onChanged: (value) => controller.updateRecognitionConfiguration( + (current) => current.copyWith( + recognizeFailedRetryInterval: value.round(), + ), + ), + ), + _RecognitionValueRow( + icon: FAssets.icons.timerReset, + label: '活体失败重试间隔', + subtitle: '活体未通过后,等待多久重新检测', + value: configuration.livenessFailedRetryInterval.toDouble(), + min: 0, + max: 5000, + step: 100, + formatter: _milliseconds, + enabled: configuration.enableLiveness && !saving, + onChanged: (value) => controller.updateRecognitionConfiguration( + (current) => current.copyWith( + livenessFailedRetryInterval: value.round(), + ), + ), + showDivider: false, + ), + ], + _CompactActionBar( + children: [ + SettingActionButton( + label: '还原默认', + icon: FAssets.icons.rotateCcw, + enabled: !saving && (!configuration.usesDefaults || changed), + onTap: controller.restoreDefaultRecognitionConfiguration, + ), + SettingActionButton( + label: saving ? '保存中...' : '保存配置', + icon: FAssets.icons.save, + primary: true, + enabled: changed && !saving, + onTap: controller.saveRecognitionConfiguration, + ), + ], + ), + ], + ), + ); + }); + } +} + +class _RecognitionValueRow extends StatelessWidget { + const _RecognitionValueRow({ + required this.icon, + required this.label, + required this.subtitle, + required this.value, + required this.min, + required this.max, + required this.step, + required this.formatter, + required this.enabled, + required this.onChanged, + this.showDivider = true, + }); + + final SvgAsset icon; + final String label; + final String subtitle; + final double value; + final double min; + final double max; + final double step; + final String Function(double value) formatter; + final bool enabled; + final ValueChanged onChanged; + final bool showDivider; + + @override + Widget build(BuildContext context) { + final normalized = ((value - min) / (max - min)).clamp(0.0, 1.0); + return SettingInfoRow( + icon: icon, + label: label, + subtitle: subtitle, + value: formatter(value), + showDivider: showDivider, + trailing: SizedBox( + width: 300.w, + child: Slider( + value: normalized, + divisions: ((max - min) / step).round(), + label: formatter(value), + onChanged: enabled + ? (offset) { + final raw = min + (offset * (max - min)); + final rounded = (raw / step).round() * step; + onChanged(rounded.clamp(min, max)); + } + : null, + ), + ), + ); + } +} + +class _RecognitionIntegerSelector extends StatelessWidget { + const _RecognitionIntegerSelector({ + required this.icon, + required this.label, + required this.subtitle, + required this.value, + required this.options, + required this.enabled, + required this.onChanged, + }); + + final SvgAsset icon; + final String label; + final String subtitle; + final int value; + final List options; + final bool enabled; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return SettingInfoRow( + icon: icon, + label: label, + subtitle: subtitle, + value: '$value', + trailing: Wrap( + spacing: 6.w, + children: options.map((option) { + final selected = option == value; + return InkWell( + onTap: enabled ? () => onChanged(option) : null, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: Container( + width: 48.w, + height: 42.h, + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected + ? XfjDesignTokens.primarySoft + : XfjDesignTokens.surface, + border: Border.all( + color: + selected ? XfjDesignTokens.primary : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Text( + '$option', + style: theme.typography.sm.copyWith( + color: + selected ? XfjDesignTokens.primary : XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + }).toList(growable: false), + ), + ); + } +} + +String _percent(double value) => '${(value * 100).round()}%'; + +String _decimal(double value) => value.toStringAsFixed(2); + +String _milliseconds(double value) => '${value.round()} ms'; + +/// 按“基本信息”的字段行组织状态,避免大屏终端出现横向仪表盘和重复操作入口。 +class _FaceStatusGroup extends StatelessWidget { + const _FaceStatusGroup({required this.controller}); + + final FaceSettingController controller; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final status = controller.status.value; + final visual = _overviewVisual(status); + final databaseCount = status?.databaseCount ?? 0; + final memoryCount = status?.memoryCount ?? 0; + return SettingGroup( + title: '人脸识别状态', + child: Column( + children: [ + SettingInfoRow( + icon: visual.icon, + label: '当前状态', + subtitle: visual.description, + value: visual.title, + trailing: Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 7.h), + decoration: BoxDecoration( + color: visual.color.withValues(alpha: 0.08), + border: Border.all( + color: visual.color.withValues(alpha: 0.22), + ), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + child: Text( + status?.ready == true ? '可用' : '需处理', + style: theme.typography.sm.copyWith( + color: visual.color, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + SettingInfoRow( + icon: FAssets.icons.shieldCheck, + label: '插件授权', + subtitle: _activationPeriod(status), + value: status?.activated == true ? '已激活' : '未激活', + ), + SettingInfoRow( + icon: FAssets.icons.scanFace, + label: '引擎初始化', + value: status?.engineReady == true + ? '正常' + : '未就绪(代码 ${status?.engineCode ?? '-'})', + ), + SettingInfoRow( + icon: status?.libraryConsistent == true + ? FAssets.icons.circleCheck + : FAssets.icons.triangleAlert, + label: '人脸库状态', + subtitle: '本地样本 $databaseCount 条,内存人脸 $memoryCount 条', + value: _libraryConsistency(status), + ), + SettingInfoRow( + icon: FAssets.icons.refreshCw, + label: '最近注册结果', + value: controller.lastResult.value?.message ?? '暂无注册记录', + showDivider: false, + ), + _CompactActionBar( + children: [ + SettingActionButton( + label: '刷新状态', + icon: FAssets.icons.refreshCw, + enabled: !controller.isBusy, + onTap: controller.refreshStatus, + ), + if (databaseCount > 0) + SettingActionButton( + label: '人脸管理', + icon: FAssets.icons.scanFace, + onTap: controller.goFaceManage, + ), + if (controller.permissionCanOpenSettings.value) + SettingActionButton( + label: '系统设置', + icon: FAssets.icons.settings, + onTap: controller.openPermissionSettings, + ), + _PrimaryAction(controller: controller), + ], + ), + ], + ), + ); + } +} + +class _ActivationParameterGroup extends StatelessWidget { + const _ActivationParameterGroup({required this.controller}); + + final FaceSettingController controller; + + @override + Widget build(BuildContext context) { + // 子组件需自行建立响应式作用域,确保脱敏开关和参数值能随 Rx 状态即时刷新。 + return Obx(() { + final status = controller.status.value; + final sys = controller.sysSetting; + final showFull = controller.showFullParameters.value; + return SettingGroup( + title: '人脸激活参数', + child: Column( + children: [ + SettingInfoRow( + icon: FAssets.icons.cloudDownload, + label: '参数同步', + subtitle: 'APP_ID、SDK_KEY、ACTIVE_KEY 仅使用后端设备接口下发值', + value: status?.parametersComplete == true ? '已同步完整' : '参数缺失', + trailing: status?.parametersComplete == true + ? SettingActionButton( + label: + controller.isSyncingConfig.value ? '同步中...' : '重新同步', + icon: FAssets.icons.cloudDownload, + enabled: !controller.isBusy, + onTap: controller.syncConfig, + ) + : null, + ), + SettingInfoRow( + icon: showFull ? FAssets.icons.eye : FAssets.icons.eyeOff, + label: '显示完整参数', + subtitle: '默认脱敏显示,现场排查时可临时查看并复制完整值', + value: showFull ? '完整显示' : '已脱敏', + trailing: FSwitch( + semanticLabel: '显示完整人脸激活参数', + value: showFull, + onChange: (_) => controller.toggleParameterVisibility(), + ), + ), + _CredentialRow( + label: 'APP_ID', + value: sys?.faceAppId, + showFull: showFull, + ), + _CredentialRow( + label: 'SDK_KEY', + value: sys?.faceSdkKey, + showFull: showFull, + ), + _CredentialRow( + label: 'ACTIVE_KEY', + value: sys?.faceActiveKey, + showFull: showFull, + showDivider: false, + ), + ], + ), + ); + }); + } +} + +class _RegistrationProgressCard extends StatelessWidget { + const _RegistrationProgressCard({required this.controller}); + + final FaceSettingController controller; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final progress = controller.progress.value; + final result = controller.lastResult.value; + final fraction = progress?.fraction ?? (result == null ? 0 : 1); + final processed = progress?.processed ?? result?.total ?? 0; + final total = progress?.total ?? result?.total ?? 0; + final success = progress?.successCount ?? result?.successCount ?? 0; + final failed = progress?.failedCount ?? result?.failedCount ?? 0; + final percent = (fraction * 100).round(); + final semanticsLabel = + '${progress?.message ?? result?.message ?? '注册进度'},$percent%,' + '已处理 $processed/$total,成功 $success,失败 $failed'; + return Semantics( + liveRegion: progress?.inProgress != true, + label: semanticsLabel, + child: SettingGroup( + title: '注册进度', + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 14.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + _phaseTitle(progress?.phase), + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + '$percent%', + style: theme.typography.xl.copyWith( + color: XfjDesignTokens.primary, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + SizedBox(height: 12.h), + ClipRRect( + borderRadius: BorderRadius.circular(99), + child: LinearProgressIndicator( + minHeight: 10.h, + value: total <= 0 && progress?.inProgress == true + ? null + : fraction, + backgroundColor: XfjDesignTokens.control, + valueColor: const AlwaysStoppedAnimation( + XfjDesignTokens.primary, + ), + ), + ), + SizedBox(height: 12.h), + Wrap( + spacing: 20.w, + runSpacing: 8.h, + children: [ + Text('处理 $processed / $total'), + Text('成功 $success'), + Text('失败 $failed'), + if (progress?.currentSampleId != null) + Text('当前样本 ${_maskFaceId(progress!.currentSampleId!)}'), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _FaceRecoveryCard extends StatelessWidget { + const _FaceRecoveryCard({required this.controller}); + + final FaceSettingController controller; + + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final failed = + controller.lastResult.value?.failedSampleIds ?? const []; + final visible = failed.take(8).map(_maskFaceId).join('、'); + final remaining = failed.length > 8 ? failed.length - 8 : 0; + return Semantics( + liveRegion: true, + child: SettingGroup( + title: '异常说明', + child: Padding( + padding: EdgeInsets.all(16.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingNotice( + text: controller.errorMessage.value.isNotEmpty + ? controller.errorMessage.value + : '本次注册存在失败样本,原始记录和图片已保留', + color: XfjDesignTokens.danger, + background: XfjDesignTokens.danger.withValues(alpha: 0.05), + borderColor: XfjDesignTokens.danger.withValues(alpha: 0.25), + icon: FAssets.icons.triangleAlert, + ), + if (failed.isNotEmpty) ...[ + SizedBox(height: 10.h), + Text( + '失败样本:$visible${remaining > 0 ? ',另有 $remaining 条' : ''}', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.ink, + ), + ), + ], + SizedBox(height: 10.h), + Row( + children: [ + Expanded( + child: Text( + '请在人脸管理中检查照片质量或重新同步,失败数据不会被自动删除。', + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + ), + ), + ), + SizedBox(width: 16.w), + SettingActionButton( + label: '前往人脸管理', + icon: FAssets.icons.scanFace, + onTap: controller.goFaceManage, + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _PrimaryAction extends StatelessWidget { + const _PrimaryAction({required this.controller}); + + final FaceSettingController controller; + + @override + Widget build(BuildContext context) { + // 按钮的加载状态需要独立订阅,否则点击重绘后会停留在“同步中”。 + return Obx(() { + final status = controller.status.value; + if (status?.parametersComplete != true) { + return SettingActionButton( + label: controller.isSyncingConfig.value ? '同步中...' : '同步设备配置', + icon: FAssets.icons.cloudDownload, + primary: true, + enabled: !controller.isBusy, + onTap: controller.syncConfig, + ); + } + if (status?.activated != true) { + return SettingActionButton( + label: controller.isActivating.value ? '激活中...' : '立即激活', + icon: FAssets.icons.shieldCheck, + primary: true, + enabled: !controller.isBusy, + onTap: controller.activate, + ); + } + if ((status?.databaseCount ?? 0) == 0) { + return SettingActionButton( + label: '前往人脸管理同步', + icon: FAssets.icons.scanFace, + primary: true, + onTap: controller.goFaceManage, + ); + } + return SettingActionButton( + label: controller.isRegistering.value ? '注册中...' : '重新注册', + icon: FAssets.icons.rotateCcw, + primary: status?.ready != true, + enabled: !controller.isBusy, + onTap: controller.rebuildLibrary, + ); + }); + } +} + +class _CompactActionBar extends StatelessWidget { + const _CompactActionBar({required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 10.h), + decoration: const BoxDecoration( + color: XfjDesignTokens.canvas, + border: Border(top: BorderSide(color: XfjDesignTokens.line)), + ), + child: Wrap( + alignment: WrapAlignment.end, + spacing: 8.w, + runSpacing: 8.h, + children: children, + ), + ); + } +} + +class _CredentialRow extends StatelessWidget { + const _CredentialRow({ + required this.label, + required this.value, + required this.showFull, + this.showDivider = true, + }); + + final String label; + final String? value; + final bool showFull; + final bool showDivider; + + @override + Widget build(BuildContext context) { + final available = value != null && value!.trim().isNotEmpty; + return SettingInfoRow( + icon: FAssets.icons.keyRound, + label: label, + value: available + ? (showFull ? value!.trim() : _maskCredential(value!.trim())) + : '未同步', + actionLabel: showFull && available ? '复制' : null, + onTap: showFull && available ? () => KeywordTool.copy(value) : null, + showDivider: showDivider, + ); + } +} + +class _OverviewVisual { + const _OverviewVisual({ + required this.title, + required this.description, + required this.icon, + required this.color, + }); + + final String title; + final String description; + final SvgAsset icon; + final Color color; +} + +_OverviewVisual _overviewVisual(FacePluginStatus? status) { + if (status == null) { + return _OverviewVisual( + title: '状态读取中', + description: '正在检查插件授权、引擎和内存人脸库', + icon: FAssets.icons.refreshCw, + color: XfjDesignTokens.muted, + ); + } + if (!status.parametersComplete) { + return _OverviewVisual( + title: '等待同步配置', + description: '后端下发的人脸插件参数不完整,请先同步设备配置', + icon: FAssets.icons.cloudDownload, + color: XfjDesignTokens.warning, + ); + } + if (!status.activated) { + return _OverviewVisual( + title: '人脸插件待激活', + description: '参数已同步,完成授权后才能初始化引擎和注册人脸库', + icon: FAssets.icons.shieldCheck, + color: XfjDesignTokens.warning, + ); + } + if (status.registrationInProgress) { + return _OverviewVisual( + title: '人脸库注册中', + description: '后台正在准备特征并装载到内存库,页面可安全离开', + icon: FAssets.icons.refreshCw, + color: XfjDesignTokens.primary, + ); + } + if (status.databaseCount == 0) { + return _OverviewVisual( + title: '等待同步人脸', + description: '插件已激活,本地数据库暂无人脸样本', + icon: FAssets.icons.database, + color: XfjDesignTokens.warning, + ); + } + if (status.ready) { + return _OverviewVisual( + title: '人脸识别就绪', + description: '授权、引擎和内存人脸库均正常,可用于人脸支付', + icon: FAssets.icons.check, + color: XfjDesignTokens.success, + ); + } + return _OverviewVisual( + title: '人脸库待重新注册', + description: '本地样本与内存库不一致,重新注册后可恢复识别', + icon: FAssets.icons.triangleAlert, + color: XfjDesignTokens.danger, + ); +} + +String _activationPeriod(FacePluginStatus? status) { + if (status?.activated != true) return '未获取到有效授权文件'; + final start = status?.activationStartTime; + final end = status?.activationEndTime; + if (start == null && end == null) return '授权文件有效'; + if (start == null) return '有效至 $end'; + if (end == null) return '自 $start 生效'; + return '$start 至 $end'; +} + +String _libraryConsistency(FacePluginStatus? status) { + if (status?.registrationInProgress == true) return '注册处理中'; + if (status?.databaseCount == 0) return '暂无样本'; + return status?.libraryConsistent == true ? '数量一致' : '数量不一致,需重新注册'; +} + +String _phaseTitle(String? phase) { + return switch (phase) { + 'preparing' => '准备人脸特征', + 'registering' => '装载内存人脸库', + 'completed' => '注册完成', + 'completedWithErrors' => '注册完成,存在异常', + _ => '注册状态', + }; +} + +String _maskFaceId(int value) { + final text = value.toString(); + if (text.length <= 4) return '***$text'; + return '${text.substring(0, 1)}***${text.substring(text.length - 3)}'; +} + +String _maskCredential(String value) { + if (value.length <= 8) return '••••••••'; + return '${value.substring(0, 4)}••••••••${value.substring(value.length - 4)}'; +} diff --git a/lib/pages/setting/widgets/setting_product.dart b/lib/pages/setting/widgets/setting_product.dart index ee6e3f5..308bdbc 100644 --- a/lib/pages/setting/widgets/setting_product.dart +++ b/lib/pages/setting/widgets/setting_product.dart @@ -1,14 +1,18 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:forui/forui.dart'; import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:get/get.dart'; +import 'package:common_utils/common_utils.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; -class ProductManageController extends GetxController { - static const _tag = "商品管理 Controller"; +import 'setting_components.dart'; +/// 商品管理页控制器,食堂模式下按应用级餐段读取商品快照。 +class ProductManageController extends GetxController { final appC = Get.find(); late SysSetting sysSetting; @@ -30,33 +34,71 @@ class ProductManageController extends GetxController { RxInt totalPage = RxInt(0); RxInt pageSize = RxInt(24); - final hourTypeC = FRadioSelectGroupController( - value: ProductHourType.MORNING.code, - ); + RxBool isLoading = false.obs; - @override - void onInit() async { - super.onInit(); - sysSetting = await AppSettingTool.getSysSetting(); - sysType.value = sysSetting.sysType; - await getProductList(); - hourTypeC.addValueListener((v) async { - final currentHourType = ProductHourType.values.fromCode( - v.first, - ); - if (currentHourType == null) return; - hourType = currentHourType; - await doPage(page: 1); - }); + RxString errorMessage = ''.obs; + + /// 获取应用级餐段控制器,兼容独立打开设置页的非标准启动方式。 + ProductHourTypeControl get _hourTypeControl { + if (!Get.isRegistered()) { + Get.put(ProductHourTypeControl(), permanent: true); + } + return Get.find(); } + /// 商品管理页复用的全局 Forui 餐段选择器。 + FRadioSelectGroupController get hourTypeC => + _hourTypeControl.selection; + + /// 全局餐段选择监听句柄,页面销毁时用于解除订阅。 + late final ValueChanged> _hourTypeListener; + + /// 商品管理页首次初始化任务,供餐段时间校验等待缓存恢复。 + Future? _initializationFuture; + + /// 商品列表完成首次加载后为 true,此后餐段变化才触发重新分页。 + bool _mealResourcesReady = false; + + /// 商品管理页餐段初始化完成信号。 + Future get initialization => _initializationFuture ?? Future.value(); + + /// 注册全局餐段监听并开始读取设置与商品列表。 + @override + void onInit() { + super.onInit(); + _hourTypeListener = _handleHourTypeSelection; + hourTypeC.addValueListener(_hourTypeListener); + _initializationFuture = _initialize(); + } + + /// 等待全局餐段缓存后加载终端模式与对应商品列表。 + Future _initialize() async { + await _hourTypeControl.initAsync(); + sysSetting = await AppSettingTool.getSysSetting(); + sysType.value = sysSetting.sysType; + hourType = _hourTypeControl.selected.value; + await getProductList(); + _mealResourcesReady = true; + } + + /// 页面销毁时释放页面资源,并保留应用级餐段选择器。 @override void onClose() { super.onClose(); - hourTypeC.dispose(); + hourTypeC.removeValueListener(_hourTypeListener); selfCodeC.dispose(); } + /// 接收全局餐段变化,并在列表初始化完成后回到第一页重新查询。 + void _handleHourTypeSelection(Set values) { + final currentHourType = ProductHourType.values.fromCode(values.firstOrNull); + if (currentHourType == null || currentHourType == ProductHourType.TOTAL) { + return; + } + hourType = currentHourType; + if (_mealResourcesReady) unawaited(doPage(page: 1)); + } + // 更新商品和分类 Future updateProductAndClassify() async { EasyLoading.show( @@ -66,6 +108,10 @@ class ProductManageController extends GetxController { try { await SyncProductTool.updateDatabase(); await getProductList(); + Toast.success(msg: '商品和分类更新成功'); + } catch (e, stack) { + Toast.error(msg: '商品和分类更新失败'); + logger.e('|商品管理|更新失败error:|$e|stack:|$stack|'); } finally { EasyLoading.dismiss(); } @@ -73,6 +119,11 @@ class ProductManageController extends GetxController { // 删除全部 Future doDelAll() async { + final confirmed = await appMessageDialog( + Get.context!, + '是否删除全部本地商品和分类数据?删除后需重新同步。', + ); + if (!confirmed) return; EasyLoading.show( status: '删除中...', maskType: EasyLoadingMaskType.black, @@ -80,6 +131,10 @@ class ProductManageController extends GetxController { try { await SyncProductTool.delDatabase(); await getProductList(); + Toast.success(msg: '本地商品数据已删除'); + } catch (e, stack) { + Toast.error(msg: '删除本地商品数据失败'); + logger.e('|商品管理|删除全部失败error:|$e|stack:|$stack|'); } finally { EasyLoading.dismiss(); } @@ -87,6 +142,8 @@ class ProductManageController extends GetxController { // 获取商品列表 Future getProductList({bool isLoading = false}) async { + this.isLoading.value = true; + errorMessage.value = ''; if (isLoading) { EasyLoading.show( status: '查询中...', @@ -112,19 +169,47 @@ class ProductManageController extends GetxController { } else if (sysType == SysType.CANTEEN) { // 食堂 final productDao = appC.dataBase.getCanteenSpuDao; - final findCount = await productDao.getCount( - currnetHourType, - ); - totalPage.value = findCount ?? 0; - final list = await productDao.getAllPage( - "-1", - currnetHourType, - pageSize.value, - (currentPage.value - 1) * pageSize.value, - ); - canteenSpuList.value = list; + final keyword = selfCodeC.text.trim().toLowerCase(); + if (keyword.isEmpty) { + final findCount = await productDao.getCount(currnetHourType); + totalPage.value = findCount ?? 0; + canteenSpuList.value = await productDao.getAllPage( + "-1", + currnetHourType, + pageSize.value, + (currentPage.value - 1) * pageSize.value, + ); + } else { + final saleDate = DateUtil.formatDate( + DateTime.now(), + format: DateFormats.y_mo_d, + ); + final allProducts = await productDao.getAll( + "-1", + currnetHourType, + saleDate, + ); + // 食堂商品查询沿用现有 DAO 契约,在当前餐段结果内按名称或编码筛选后分页。 + final filteredProducts = allProducts.where((product) { + return product.name.toLowerCase().contains(keyword) || + product.serviceUniqueCode.toLowerCase().contains(keyword); + }).toList(); + totalPage.value = filteredProducts.length; + final start = (currentPage.value - 1) * pageSize.value; + final end = (start + pageSize.value).clamp( + 0, + filteredProducts.length, + ); + canteenSpuList.value = start >= filteredProducts.length + ? [] + : filteredProducts.sublist(start, end); + } } + } catch (e, stack) { + errorMessage.value = '商品数据加载失败,请稍后重试'; + logger.e('|商品管理|加载失败error:|$e|stack:|$stack|'); } finally { + this.isLoading.value = false; if (isLoading) { Future.delayed(const Duration(seconds: 1), () { EasyLoading.dismiss(); @@ -168,9 +253,13 @@ class ProductManageController extends GetxController { await productDao.editDefault( editObj.isDefault, editObj.serviceUniqueCode, - hourType.index, + int.parse(hourType.code), ); - getProductList(); + await getProductList(); + Toast.success(msg: editObj.isDefault ? '已设为默认菜品' : '已取消默认菜品'); + } catch (e, stack) { + Toast.error(msg: '默认菜品设置失败'); + logger.e('|商品管理|默认菜品设置失败error:|$e|stack:|$stack|'); } finally { EasyLoading.dismiss(); } @@ -179,85 +268,76 @@ class ProductManageController extends GetxController { // 搜索条件 Widget _buildSearch(BuildContext context) { - final theme = FTheme.of(context); final productC = Get.find(); - return [ - [ - Obx( + final filters = [ + SizedBox( + width: 260.w, + child: Obx( () => appRadioHourTypeDialog( context, productC.hourTypeC, + checkConfiguredTime: true, + ready: productC.initialization, ).offstage( offstage: productC.sysType.value != SysType.CANTEEN, ), - ).width(300.w), - ].toRow().expanded(), - Obx( - () => FTextField( - textInputAction: TextInputAction.none, - keyboardType: TextInputType.none, - controller: productC.selfCodeC, - hint: '请输入自编码/商品名', - maxLines: 1, - onSubmit: (_) => productC.doPage(), - ).offstage( - offstage: productC.sysType.value != SysType.SUPERMARKET, ), - ).width(300.w), - FButton( - prefix: FIcon( - FAssets.icons.ban, - size: theme.typography.base.fontSize, - ), - label: Text( - '重置', - style: theme.typography.base.copyWith( - color: theme.colorScheme.secondary, - ), - ), - onPress: () => productC.doReset(), ), - FButton( - prefix: FIcon( - FAssets.icons.search, - size: theme.typography.base.fontSize, - ), - label: Text( - '搜索', - style: theme.typography.base.copyWith( - color: theme.colorScheme.secondary, - ), - ), - onPress: () => productC.doPage(), + TerminalToolbarTextField( + width: 360.w, + textInputAction: TextInputAction.none, + keyboardType: TextInputType.none, + controller: productC.selfCodeC, + hint: '请输入自编码/商品名', + onSubmit: (_) => productC.doPage(page: 1), ), - FButton( - prefix: FIcon( - FAssets.icons.pencilRuler, - size: theme.typography.base.fontSize, - ), - label: Text( - '更新商品和分类', - style: theme.typography.base.copyWith( - color: theme.colorScheme.secondary, - ), - ), - onPress: () => productC.updateProductAndClassify(), + TerminalToolbarButton( + label: '重置', + icon: FAssets.icons.ban, + style: TerminalToolbarButtonStyle.secondary, + onTap: productC.doReset, ), - FButton( - prefix: FIcon( - FAssets.icons.trash2, - size: theme.typography.base.fontSize, - ), - style: FButtonStyle.destructive, - label: Text( - '删除全部数据', - style: theme.typography.base.copyWith( - color: theme.colorScheme.secondary, - ), - ), - onPress: () => productC.doDelAll(), + TerminalToolbarButton( + label: '搜索', + icon: FAssets.icons.search, + onTap: () => productC.doPage(page: 1), ), - ].toRowSpace(space: 20.w); + TerminalToolbarButton( + label: '更新商品和分类', + icon: FAssets.icons.pencilRuler, + minWidth: 188.w, + onTap: productC.updateProductAndClassify, + ), + ]; + return LayoutBuilder( + builder: (context, constraints) { + final deleteButton = TerminalToolbarButton( + label: '删除全部数据', + icon: FAssets.icons.trash2, + style: TerminalToolbarButtonStyle.danger, + minWidth: 190.w, + onTap: productC.doDelAll, + ); + return Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + ...filters.expand( + (item) => [item, SizedBox(width: 12.w)], + ), + ], + ), + ), + ), + SizedBox(width: 16.w), + SizedBox(width: 190.w, child: deleteButton), + ], + ); + }, + ); } // 食堂列表 @@ -265,42 +345,84 @@ Widget _buildCanteenList(BuildContext context) { final theme = FTheme.of(context); final productC = Get.find(); return Obx( - () => GridView.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 8, - mainAxisSpacing: 10.w, - crossAxisSpacing: 10.w, - childAspectRatio: 0.7, - ), - itemCount: productC.canteenSpuList.length, - itemBuilder: (BuildContext context, int index) { - final v = productC.canteenSpuList[index]; - return FCard( - style: theme.cardStyle.copyWith( - contentStyle: theme.cardStyle.contentStyle.copyWith( - padding: EdgeInsets.all(0), + () { + if (productC.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + if (productC.errorMessage.isNotEmpty) { + return SettingEmpty(message: productC.errorMessage.value); + } + if (productC.canteenSpuList.isEmpty) { + return const SettingEmpty(message: '暂无本地菜品'); + } + return GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + mainAxisSpacing: XfjDesignTokens.cardGap.h, + crossAxisSpacing: XfjDesignTokens.cardGap.w, + childAspectRatio: 1.45, + ), + itemCount: productC.canteenSpuList.length, + itemBuilder: (BuildContext context, int index) { + final v = productC.canteenSpuList[index]; + return Container( + padding: EdgeInsets.all(12.w), + decoration: BoxDecoration( + color: XfjDesignTokens.surface, + border: Border.all(color: XfjDesignTokens.line), + borderRadius: BorderRadius.circular(XfjDesignTokens.cardRadius), + boxShadow: XfjDesignTokens.surfaceShadow, ), - ), - image: appImg(v.image, width: double.infinity, height: 120.h), - title: Text( - v.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.typography.xs, - ).center(), - subtitle: appPrice(context, v.price.toString()) - .center() - .paddingOnly(top: 10.h), - child: [ - Text("是否默认", style: theme.typography.sm), - FSwitch( - value: v.isDefault, - onChange: (value) => productC.trigger(v), - ) - ].toRow(mainAxisAlignment: MainAxisAlignment.center), - ); - }, - ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + child: appImg(v.image, width: 86.w, height: 86.w), + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + v.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.base.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: 8.h), + appPrice(context, v.price.toString()), + SizedBox(height: 8.h), + Row( + children: [ + Text( + '默认菜品', + style: theme.typography.xs.copyWith( + color: XfjDesignTokens.muted, + ), + ), + const Spacer(), + FSwitch( + value: v.isDefault, + onChange: (_) => productC.trigger(v), + ), + ], + ), + ], + ), + ), + ], + ), + ); + }, + ); + }, ); } @@ -308,26 +430,37 @@ Widget _buildCanteenList(BuildContext context) { Widget _buildSupermarketList(BuildContext context) { final productC = Get.find(); return Obx( - () => GridView.builder( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 6, - mainAxisSpacing: 10.w, - crossAxisSpacing: 10.w, - childAspectRatio: 0.9, - ), - itemCount: productC.supermarketSpuList.length, - itemBuilder: (BuildContext context, int index) { - final v = productC.supermarketSpuList[index]; - return orderSupermarketSpuCard(context, supermarketSpu: v); - }, - ), + () { + if (productC.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + if (productC.errorMessage.isNotEmpty) { + return SettingEmpty(message: productC.errorMessage.value); + } + if (productC.supermarketSpuList.isEmpty) { + return const SettingEmpty(message: '暂无本地商品'); + } + return GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 5, + mainAxisSpacing: XfjDesignTokens.cardGap.h, + crossAxisSpacing: XfjDesignTokens.cardGap.w, + childAspectRatio: 1.25, + ), + itemCount: productC.supermarketSpuList.length, + itemBuilder: (BuildContext context, int index) { + final v = productC.supermarketSpuList[index]; + return orderSupermarketSpuCard(context, supermarketSpu: v); + }, + ); + }, ); } Widget settingProduct(BuildContext context) { - final productC = Get.put( - ProductManageController(), - ); + final productC = Get.isRegistered() + ? Get.find() + : Get.put(ProductManageController()); return [ _buildSearch(context), Obx( @@ -346,5 +479,5 @@ Widget settingProduct(BuildContext context) { onPageChanged: (pageNo) => productC.doPage(page: pageNo), ), ) - ].toColumnSpace(space: 10.h); + ].toColumnSpace(space: XfjDesignTokens.cardGap.h); } diff --git a/lib/pages/setting/widgets/setting_sync.dart b/lib/pages/setting/widgets/setting_sync.dart index a80d735..265ea62 100644 --- a/lib/pages/setting/widgets/setting_sync.dart +++ b/lib/pages/setting/widgets/setting_sync.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:common_utils/common_utils.dart'; import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/material.dart'; @@ -6,91 +8,518 @@ import 'package:get/get.dart'; import 'package:pj_supermarket_cloud/common/index.dart'; -import '../index.dart'; +import '../controller.dart'; +import '../routes/navigation.dart'; +import 'setting_components.dart'; +import 'setting_cron_rule_dialog.dart'; +/// 同步设置只读取统一调度器状态;开关、规则和立即执行均由 SettingController 持久化或触发。 Widget settingSync(BuildContext context) { - String dateFormat(DateTime? dataTime) { - if (dataTime == null) return ""; - return DateUtil.formatDate(dataTime); - } - - return GetBuilder(builder: (controller) { - return Obx(() { - final sys = controller.sysSetting.value; - if (sys == null) return const SizedBox.shrink(); - - String getSyncClassifyTime() { - if (sys.sysType == SysType.CANTEEN) { - return dateFormat(sys.canteenClassifySyncTime); - } else if (sys.sysType == SysType.SUPERMARKET) { - return dateFormat(sys.supermarketClassifySyncTime); - } else { - return "未同步"; - } - } - - String getSyncProductTime() { - if (sys.sysType == SysType.CANTEEN) { - return dateFormat(sys.canteenProductSyncTime); - } else if (sys.sysType == SysType.SUPERMARKET) { - return dateFormat(sys.supermarketProductSyncTime); - } else { - return "未同步"; - } - } - - return [ - FTile( - prefixIcon: appPrefixIcon(context, FAssets.icons.settings), - suffixIcon: appSuffixIcon(context, FAssets.icons.refreshCw), - title: appPrefixTitle(context, "同步设备信息"), - details: appSuffixTitle( - context, - "最后同步时间:${dateFormat(sys.syncTime)}", - ), - onPress: () => controller.syncConfig(), - ), - FTile( - prefixIcon: appPrefixIcon(context, FAssets.icons.settings), - suffixIcon: appSuffixIcon(context, FAssets.icons.refreshCw), - title: appPrefixTitle(context, "同步长链接"), - details: appSuffixTitle( - context, - "最后同步时间:${dateFormat(sys.mqSyncTime)}", - ), - onPress: () => controller.syncMq(), - ), - FTile( - prefixIcon: appPrefixIcon(context, FAssets.icons.settings), - suffixIcon: appSuffixIcon(context, FAssets.icons.refreshCw), - title: appPrefixTitle(context, "同步分类"), - details: appSuffixTitle( - context, - "最后同步时间:${getSyncClassifyTime()}", - ), - onPress: () => controller.syncClassify(), - ), - FTile( - prefixIcon: appPrefixIcon(context, FAssets.icons.settings), - suffixIcon: appSuffixIcon(context, FAssets.icons.refreshCw), - title: appPrefixTitle(context, "同步商品"), - details: appSuffixTitle( - context, - "最后同步时间:${getSyncProductTime()}", - ), - onPress: () => controller.syncProduct(), - ), - FTile( - prefixIcon: appPrefixIcon(context, FAssets.icons.settings), - suffixIcon: appSuffixIcon(context, FAssets.icons.refreshCw), - title: appPrefixTitle(context, "同步离线订单"), - details: appSuffixTitle( - context, - "最后同步时间:${getSyncProductTime()}", - ), - onPress: () => controller.syncOfflineOrder(), - ), - ].toColumnSpace(space: 20.h); - }); + final controller = Get.find(); + final scheduler = SyncCheckTool.instance; + return Obx(() { + final configs = [ + for (final type in ScheduledTaskType.values) + if (scheduler.configs[type] != null) scheduler.configs[type]!, + ]; + if (configs.isEmpty) return const SettingEmpty(message: '正在加载同步任务'); + return [ + SettingGroup( + title: '自动任务', + child: [ + for (var index = 0; index < configs.length; index++) + _ScheduledTaskRow( + config: configs[index], + record: scheduler.records[configs[index].type], + running: scheduler.runningTasks.contains(configs[index].type), + pending: scheduler.pendingTasks.contains(configs[index].type), + saving: + controller.savingScheduledTasks.contains(configs[index].type), + showDivider: index != configs.length - 1, + controller: controller, + ), + ].toColumn(), + ), + _terminalLogSummary(context, scheduler.terminalLogSummary.value), + const SettingNotice( + text: + '定时任务仅在应用进程存活时执行。自动空闲任务要求前台连续 60 秒无操作;手动执行和后台拉取只等待支付、人脸、遮罩和业务弹框结束。', + ), + ] + .toColumnSpace( + crossAxisAlignment: CrossAxisAlignment.stretch, + space: XfjDesignTokens.cardGap.h, + ) + .scrollable(); }); } + +/// 单个定时任务的紧凑配置行,任务标题区域同时作为执行日志入口。 +class _ScheduledTaskRow extends StatelessWidget { + /// 创建由调度器快照和设置控制器共同驱动的任务行。 + const _ScheduledTaskRow({ + required this.config, + required this.record, + required this.running, + required this.pending, + required this.saving, + required this.showDivider, + required this.controller, + }); + + /// 当前任务的自动开关、Cron 和空闲策略配置。 + final ScheduledTaskConfig config; + + /// 当前任务最近一次执行结果;从未执行或尚未加载时为空。 + final ScheduledTaskRecord? record; + + /// 当前任务是否正在实际执行业务动作。 + final bool running; + + /// 当前任务是否正在等待安全窗口或等待同类任务结束。 + final bool pending; + + /// 当前任务配置是否正在写入 SQLite,用于暂时禁用编辑控件。 + final bool saving; + + /// 是否在本行底部展示与下一任务之间的分隔线。 + final bool showDivider; + + /// 当前设置页 Binding 注册的控制器,负责保存配置和立即执行。 + final SettingController controller; + + /// 构建日志入口、Cron 规则、空闲开关、自动开关和立即执行操作。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + final metadata = _taskMetadata(config.type); + final status = _taskStatus(record, running: running, pending: pending); + final rowTextStyle = theme.typography.sm; + // 任务详情、规则、开关和立即执行使用独立热区,避免点击日志时误切换任务配置。 + final taskSummary = Tooltip( + message: '查看${metadata.title}执行日志', + child: InkWell( + onTap: () => unawaited( + SettingNavigation.openTaskLogs(config.type), + ), + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: [ + _taskIcon(metadata.icon, running: running), + [ + Text( + metadata.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: rowTextStyle.copyWith( + color: XfjDesignTokens.ink, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(height: 4.h), + Text( + '${status.text} · ${_formatRecordTime(record?.lastRunAt)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: rowTextStyle.copyWith(color: status.color), + ), + ].toColumn(crossAxisAlignment: CrossAxisAlignment.start).expanded(), + ].toRowSpace(space: 10.w).padding(vertical: 4.h), + ), + ); + final taskActions = [ + SizedBox(width: 8.w), + _TaskLogButton( + taskTitle: metadata.title, + onTap: () => unawaited(SettingNavigation.openTaskLogs(config.type)), + ), + SizedBox(width: 10.w), + _CronRuleButton( + expression: config.cron, + enabled: !saving, + onTap: () => _selectCron(context), + ), + SizedBox(width: 10.w), + _TaskSwitch( + label: config.idleOnly ? '仅空闲' : '直接执行', + semanticLabel: '${metadata.title}仅空闲执行', + cellWidth: 144.w, + value: config.idleOnly, + enabled: !saving, + onChange: (value) => + controller.setScheduledTaskIdleOnly(config.type, value), + ), + SizedBox(width: 10.w), + _TaskSwitch( + label: '自动', + semanticLabel: '${metadata.title}自动执行', + cellWidth: 112.w, + value: config.enabled, + enabled: !saving, + onChange: (value) => + controller.setScheduledTaskEnabled(config.type, value), + ), + const Spacer(), + SizedBox(width: 10.w), + FButton( + style: FButtonStyle.outline, + onPress: saving + ? null + : () => unawaited( + controller.runScheduledTaskNow(config.type), + ), + prefix: running + ? SizedBox.square( + dimension: 18.w, + child: const CircularProgressIndicator(strokeWidth: 2), + ) + : FIcon(FAssets.icons.refreshCw, size: 18.sp), + label: Text( + running + ? '执行中' + : pending + ? '再执行一次' + : '立即执行', + style: rowTextStyle, + ), + ).constrained(minWidth: 138.w, minHeight: 48.h), + ].toRow(); + return [ + taskSummary.expanded(flex: 5), + taskActions.expanded(flex: 5), + ] + .toRow() + .padding(horizontal: 18.w, vertical: 10.h) + .constrained(minHeight: 72.h) + .decorated( + border: showDivider + ? const Border( + bottom: BorderSide(color: XfjDesignTokens.line), + ) + : null, + ); + } + + /// 打开规则弹窗,并在用户确认了不同表达式时交由 Controller 二次校验和保存。 + Future _selectCron(BuildContext context) async { + final expression = await showScheduledCronRuleDialog( + context, + currentExpression: config.cron, + ); + if (expression == null || expression == config.cron) return; + await controller.setScheduledTaskCron(config.type, expression); + } +} + +/// 任务行中独立的结构化日志入口,图标热区不受任务摘要宽度影响。 +class _TaskLogButton extends StatelessWidget { + /// 创建固定 48dp 热区的日志页面入口。 + const _TaskLogButton({required this.taskTitle, required this.onTap}); + + /// 当前任务中文名称,用于 Tooltip 和语义说明。 + final String taskTitle; + + /// 点击后打开对应任务日志路由的回调。 + final VoidCallback onTap; + + /// 使用卷轴文本图标构建更明确的日志操作入口。 + @override + Widget build(BuildContext context) => Tooltip( + message: '查看$taskTitle执行日志', + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: FIcon( + FAssets.icons.scrollText, + color: XfjDesignTokens.primary, + size: 24.sp, + ).center().constrained(width: 48.w, height: 48.h).decorated( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ), + ), + ); +} + +/// 在任务行中垂直、水平居中展示当前规则摘要的 Cron 编辑按钮。 +class _CronRuleButton extends StatelessWidget { + /// 创建一个固定尺寸的 Cron 规则按钮。 + const _CronRuleButton({ + required this.expression, + required this.enabled, + required this.onTap, + }); + + /// 当前任务已保存的六段 Cron 表达式。 + final String expression; + + /// 是否允许打开规则弹窗;配置保存期间为假。 + final bool enabled; + + /// 打开 Cron 规则弹窗的回调。 + final VoidCallback onTap; + + /// 构建 48dp 点击热区内的紧凑 Cron 标签,保持任务行列稳定。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + return Tooltip( + message: '设置 Cron 定时规则', + child: InkWell( + onTap: enabled ? onTap : null, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + child: [ + FIcon( + FAssets.icons.clock, + color: enabled ? XfjDesignTokens.primary : XfjDesignTokens.muted, + size: 16.sp, + ), + Text( + _cronLabel(expression), + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.typography.sm.copyWith( + color: enabled ? XfjDesignTokens.primary : XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ).expanded(), + ] + .toRowSpace(space: 6.w) + .padding(horizontal: 9.w) + .constrained(width: 158.w, height: 36.h) + .decorated( + color: XfjDesignTokens.primarySoft, + border: Border.all( + color: enabled ? XfjDesignTokens.primary : XfjDesignTokens.line, + ), + borderRadius: BorderRadius.circular( + XfjDesignTokens.controlRadius, + ), + ) + .center() + .constrained(width: 164.w, height: 48.h), + ), + ); + } +} + +/// 任务行中复用的带短标签二元开关。 +class _TaskSwitch extends StatelessWidget { + /// 创建一个由父任务行管理值与保存行为的开关。 + const _TaskSwitch({ + required this.label, + required this.semanticLabel, + required this.cellWidth, + required this.value, + required this.enabled, + required this.onChange, + }); + + /// 开关左侧的短展示名称。 + final String label; + + /// 读屏使用的完整任务操作说明。 + final String semanticLabel; + + /// 本列固定宽度,分别为执行策略和自动开关保留不换行空间。 + final double cellWidth; + + /// 当前开关值。 + final bool value; + + /// 配置保存期间是否允许切换。 + final bool enabled; + + /// 值变化后交由设置控制器持久化的回调。 + final ValueChanged onChange; + + /// 构建固定尺寸标签与 Forui 开关,保证各任务行列对齐。 + @override + Widget build(BuildContext context) { + final theme = FTheme.of(context); + // 当前开关没有使用 Forui 的内置标签,移除其默认水平留白,避免固定表格列产生溢出; + // 保留垂直留白,使开关本身仍具有 48dp 的稳定触控高度。 + final switchStyle = theme.switchStyle.copyWith( + labelLayoutStyle: theme.switchStyle.labelLayoutStyle.copyWith( + childPadding: EdgeInsets.symmetric(vertical: 4.5.h), + ), + ); + return [ + Text( + label, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.typography.sm.copyWith( + color: value ? XfjDesignTokens.ink : XfjDesignTokens.muted, + fontWeight: FontWeight.w700, + ), + ).expanded(), + SizedBox(width: 6.w), + FSwitch( + style: switchStyle, + semanticLabel: semanticLabel, + value: value, + enabled: enabled, + onChange: onChange, + ), + ].toRow().constrained(width: cellWidth, minHeight: 48.h); + } +} + +/// 构建任务行固定尺寸图标;运行中用加载指示器替代业务图标。 +Widget _taskIcon(SvgAsset icon, {required bool running}) => Container( + width: 44.w, + height: 44.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: XfjDesignTokens.primarySoft, + borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), + ), + child: running + ? SizedBox.square( + dimension: 22.w, + child: const CircularProgressIndicator(strokeWidth: 2), + ) + : FIcon(icon, color: XfjDesignTokens.primary, size: 23.sp), + ); + +/// 构建最近一次日志上传的文件统计和成功时间概况。 +Widget _terminalLogSummary( + BuildContext context, + TerminalLogUploadSummary summary, +) { + final theme = FTheme.of(context); + final lastSuccess = summary.lastSuccessAt == null + ? '暂无成功记录' + : DateUtil.formatDate( + summary.lastSuccessAt!, + format: DateFormats.full, + ); + return SettingGroup( + title: '日志上传概况', + child: [ + _summaryValue(theme, '扫描', summary.scanned, XfjDesignTokens.ink), + _summaryValue(theme, '跳过', summary.skipped, XfjDesignTokens.muted), + _summaryValue(theme, '成功', summary.succeeded, XfjDesignTokens.success), + _summaryValue(theme, '失败', summary.failed, XfjDesignTokens.danger), + Text( + '最近成功:$lastSuccess', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.typography.sm.copyWith( + color: XfjDesignTokens.muted, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ).expanded(), + ] + .toRowSpace(space: 22.w) + .padding(horizontal: 20.w, vertical: 14.h) + .constrained(minHeight: 64.h), + ); +} + +/// 构建单个日志上传统计值,颜色由成功、失败等业务语义传入。 +Widget _summaryValue( + FThemeData theme, + String label, + int value, + Color color, +) => + Text( + '$label $value', + style: theme.typography.base.copyWith( + color: color, + fontWeight: FontWeight.w800, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ); + +/// 将任务类型转换为设置页使用的中文名称和图标。 +({String title, SvgAsset icon}) _taskMetadata(ScheduledTaskType type) => + switch (type) { + ScheduledTaskType.mqReconnect => ( + title: 'MQTT 重连', + icon: FAssets.icons.wifi + ), + ScheduledTaskType.printCheck => ( + title: '打印任务检查', + icon: FAssets.icons.printer + ), + ScheduledTaskType.broadcastCheck => ( + title: '取餐广播检查', + icon: FAssets.icons.receiptText + ), + ScheduledTaskType.deviceStatus => ( + title: '设备状态检查', + icon: FAssets.icons.hardDrive + ), + ScheduledTaskType.autoUpdate => ( + title: 'APK 自动更新', + icon: FAssets.icons.packageCheck + ), + ScheduledTaskType.configSync => ( + title: '设备配置同步', + icon: FAssets.icons.settings + ), + ScheduledTaskType.departmentFaceSync => ( + title: '本部门人脸同步', + icon: FAssets.icons.scanFace + ), + ScheduledTaskType.classifySync => ( + title: '分类同步', + icon: FAssets.icons.packageOpen + ), + ScheduledTaskType.productSync => ( + title: '商品同步', + icon: FAssets.icons.package + ), + ScheduledTaskType.offlineOrderSync => ( + title: '离线订单同步', + icon: FAssets.icons.cloudDownload + ), + ScheduledTaskType.orderEvidenceUpload => ( + title: '订单证据补传', + icon: FAssets.icons.image + ), + ScheduledTaskType.terminalLogUpload => ( + title: '终端日志上传', + icon: FAssets.icons.logs + ), + ScheduledTaskType.expiredLogCleanup => ( + title: '过期日志清理', + icon: FAssets.icons.trash2 + ), + }; + +/// 根据运行、等待和最近结果生成任务行状态文案与语义颜色。 +({String text, Color color}) _taskStatus( + ScheduledTaskRecord? record, { + required bool running, + required bool pending, +}) { + if (running) return (text: '正在执行', color: XfjDesignTokens.primary); + if (pending) return (text: '等待安全窗口', color: XfjDesignTokens.warning); + if (record == null) return (text: '尚未执行', color: XfjDesignTokens.muted); + return record.success + ? (text: record.message, color: XfjDesignTokens.success) + : (text: record.message, color: XfjDesignTokens.danger); +} + +/// 优先返回常用预设名称,自定义表达式无法匹配预设时展示原值。 +String _cronLabel(String cron) { + for (final preset in SyncCheckTool.cronPresets) { + if (preset.expression == cron) return preset.label; + } + return cron; +} + +/// 将最近执行时间格式化为任务行摘要,从未执行时返回明确空状态。 +String _formatRecordTime(DateTime? time) => time == null + ? '最近执行:暂无' + : '最近执行:${DateUtil.formatDate(time, format: DateFormats.full)}'; diff --git a/packages/drago_usb_printer/android/build.gradle b/packages/drago_usb_printer/android/build.gradle new file mode 100644 index 0000000..21dff80 --- /dev/null +++ b/packages/drago_usb_printer/android/build.gradle @@ -0,0 +1,40 @@ +group 'app.sks.client.drago_usb_printer' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.8.22' + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.1.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace = 'app.sks.client.drago_usb_printer' + compileSdkVersion 35 + sourceSets { main.java.srcDirs += 'src/main/kotlin' } + defaultConfig { minSdkVersion 24 } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" +} diff --git a/packages/drago_usb_printer/android/src/main/AndroidManifest.xml b/packages/drago_usb_printer/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1912c97 --- /dev/null +++ b/packages/drago_usb_printer/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/packages/drago_usb_printer/android/src/main/kotlin/app/sks/client/drago_usb_printer/DragoUsbPrinterPlugin.kt b/packages/drago_usb_printer/android/src/main/kotlin/app/sks/client/drago_usb_printer/DragoUsbPrinterPlugin.kt new file mode 100644 index 0000000..ee45435 --- /dev/null +++ b/packages/drago_usb_printer/android/src/main/kotlin/app/sks/client/drago_usb_printer/DragoUsbPrinterPlugin.kt @@ -0,0 +1,202 @@ +package app.sks.client.drago_usb_printer + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbInterface +import android.hardware.usb.UsbManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** 项目内 USB 打印插件;只有 Bulk OUT 接受全部字节后才向 Dart 返回成功。 */ +class DragoUsbPrinterPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { + /** Flutter 插件使用的应用上下文。 */ + private lateinit var context: Context + + /** 与 Dart 侧约定为 drago_usb_printer 的方法通道。 */ + private lateinit var channel: MethodChannel + + /** Android USB Host 系统服务。 */ + private lateinit var usbManager: UsbManager + + /** 以 VID-PID 为键复用已经 claim 的打印机连接。 */ + private val connections = mutableMapOf() + + /** 将后台写入结果切回主线程提交给 Flutter。 */ + private val mainHandler = Handler(Looper.getMainLooper()) + + /** 注册方法通道并取得 USB Host 服务。 */ + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext + usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager + channel = MethodChannel(binding.binaryMessenger, "drago_usb_printer") + channel.setMethodCallHandler(this) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "getUSBDeviceList" -> result.success(listPrinterDevices()) + "connect" -> runCatching { connectionFor(call).connect() } + .fold(result::success) { result.error("USB_CONNECT", it.message, null) } + "disconnect" -> { + connections.remove(deviceKey(call))?.close() + result.success(true) + } + "write" -> { + val data = call.argument("data") + if (data == null) { + result.error("USB_DATA", "Missing USB payload", null) + return + } + Thread { + runCatching { connectionFor(call).writeFully(data) } + .fold( + { mainHandler.post { result.success(true) } }, + { error -> mainHandler.post { result.error("USB_WRITE", error.message, null) } }, + ) + }.start() + } + else -> result.notImplemented() + } + } + + /** 返回至少包含一个 Printer Class 接口的 USB 设备摘要。 */ + private fun listPrinterDevices(): List> = usbManager.deviceList.values + .filter(::isPrinterDevice) + .map { device -> + mapOf( + "deviceName" to device.deviceName, + "manufacturer" to safeValue { device.manufacturerName }, + "productName" to safeValue { device.productName }, + "deviceId" to device.deviceId.toString(), + "vendorId" to device.vendorId.toString(), + "productId" to device.productId.toString(), + ) + } + + /** 校验 VID/PID、设备类型和授权后取得可复用连接。 */ + private fun connectionFor(call: MethodCall): UsbPrinterConnection { + val vendorId = call.argument("vendorId") ?: error("Missing USB VID") + val productId = call.argument("productId") ?: error("Missing USB PID") + val device = usbManager.deviceList.values.firstOrNull { + it.vendorId == vendorId && it.productId == productId && isPrinterDevice(it) + } ?: error("Configured USB printer was not found") + if (!usbManager.hasPermission(device)) { + requestPermission(device) + error("USB permission requested; retry after permission is granted") + } + return connections.getOrPut("$vendorId-$productId") { + UsbPrinterConnection(usbManager, device) + } + } + + /** 从方法参数构造连接缓存键。 */ + private fun deviceKey(call: MethodCall): String = + "${call.argument("vendorId")}-${call.argument("productId")}" + + /** 请求 Android USB 授权;授权完成后由上层恢复流程再次连接。 */ + private fun requestPermission(device: UsbDevice) { + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0 + val intent = PendingIntent.getBroadcast( + context, + 0, + Intent("${context.packageName}.USB_PERMISSION"), + flags, + ) + usbManager.requestPermission(device, intent) + } + + /** 判断设备是否公开标准 USB Printer Class 接口。 */ + private fun isPrinterDevice(device: UsbDevice): Boolean = + (0 until device.interfaceCount).any { + device.getInterface(it).interfaceClass == UsbConstants.USB_CLASS_PRINTER + } + + /** 兼容部分设备描述符读取异常,异常字段按空值返回。 */ + private fun safeValue(read: () -> String?): String? = runCatching(read).getOrNull() + + /** 释放全部已 claim 的接口和连接,并注销方法通道。 */ + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + connections.values.forEach(UsbPrinterConnection::close) + connections.clear() + channel.setMethodCallHandler(null) + } +} + +/** 持有一个已 claim 的打印机接口;Bulk OUT 必需,Bulk IN 明确为可选。 */ +private class UsbPrinterConnection( + /** Android USB Host 系统服务。 */ + private val manager: UsbManager, + + /** 当前 VID/PID 对应的 USB 设备。 */ + private val device: UsbDevice, +) { + /** 已打开的设备连接。 */ + private var connection: UsbDeviceConnection? = null + + /** 已 claim 的 Printer Class 接口。 */ + private var printerInterface: UsbInterface? = null + + /** 当前打印接口的 Bulk OUT 端点。 */ + private var bulkOut: UsbEndpoint? = null + + /** 打开设备并 claim Printer Class 接口,缺少 Bulk OUT 时直接失败。 */ + @Synchronized + fun connect(): Boolean { + if (connection != null && bulkOut != null) return true + val targetInterface = (0 until device.interfaceCount) + .map(device::getInterface) + .firstOrNull { it.interfaceClass == UsbConstants.USB_CLASS_PRINTER } + ?: error("USB printer interface is missing") + val output = (0 until targetInterface.endpointCount) + .map(targetInterface::getEndpoint) + .firstOrNull { + it.type == UsbConstants.USB_ENDPOINT_XFER_BULK && + it.direction == UsbConstants.USB_DIR_OUT + } ?: error("USB Bulk OUT endpoint is missing") + val opened = manager.openDevice(device) ?: error("Unable to open USB printer") + if (!opened.claimInterface(targetInterface, true)) { + opened.close() + error("Unable to claim USB printer interface") + } + connection = opened + printerInterface = targetInterface + bulkOut = output + return true + } + + @Synchronized + fun writeFully(data: ByteArray): Int { + connect() + val opened = connection ?: error("USB connection is closed") + val output = bulkOut ?: error("USB Bulk OUT endpoint is missing") + var offset = 0 + while (offset < data.size) { + val written = opened.bulkTransfer(output, data, offset, data.size - offset, 5000) + if (written <= 0) error("USB write stopped at $offset/${data.size} bytes (result $written)") + offset += written + } + if (offset != data.size) error("USB write incomplete: $offset/${data.size} bytes") + return offset + } + + @Synchronized + fun close() { + printerInterface?.let { connection?.releaseInterface(it) } + connection?.close() + connection = null + printerInterface = null + bulkOut = null + } +} + /** 分发设备发现、连接、关闭和完整写入四类原生调用。 */ + /** 循环处理 bulkTransfer 部分写入,并核对最终累计长度。 */ + /** 释放接口并关闭设备连接,重复调用保持幂等。 */ diff --git a/packages/drago_usb_printer/lib/drago_usb_printer.dart b/packages/drago_usb_printer/lib/drago_usb_printer.dart new file mode 100644 index 0000000..151c9ef --- /dev/null +++ b/packages/drago_usb_printer/lib/drago_usb_printer.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +/// `drago_pos_printer` 在 Android 上使用的项目内 USB Bulk 传输实现。 +class DragoUsbPrinter { + /// 与原生插件约定的稳定 MethodChannel 名称。 + static const MethodChannel _channel = MethodChannel('drago_usb_printer'); + + /// 当前连接目标的 USB Vendor ID,连接前为 0。 + int vendorId = 0; + + /// 当前连接目标的 USB Product ID,连接前为 0。 + int productId = 0; + + /// 列出 Android 上具有 Printer Class 接口的 USB 设备,授权仍由 [connect] 触发。 + static Future>> getUSBDeviceList() async { + if (!Platform.isAndroid) return >[]; + final devices = + await _channel.invokeListMethod('getUSBDeviceList'); + return (devices ?? const []) + .cast>() + .map((item) => Map.from(item)) + .toList(growable: false); + } + + /// 按准确 VID/PID 打开设备,并要求目标接口至少存在一个 Bulk OUT 端点。 + Future connect(int vendorId, int productId) async { + this.vendorId = vendorId; + this.productId = productId; + return await _channel.invokeMethod('connect', _params) == true; + } + + /// 释放当前原生 USB Interface 与连接;重复关闭按成功处理。 + Future close() async { + return await _channel.invokeMethod('disconnect', _params) == true; + } + + /// 等待原生层完整接收全部字节后返回;部分写、零字节或负数结果均抛出异常。 + Future write(Uint8List data) async { + if (data.isEmpty) return true; + final completed = await _channel.invokeMethod('write', { + ..._params, + 'data': data, + }); + if (completed != true) { + throw StateError('USB did not accept the complete payload'); + } + return true; + } + + /// 保留上游可能使用的文本兼容入口;生产图片打印直接调用 [write]。 + Future printText(String text) => + write(Uint8List.fromList(text.codeUnits)); + + /// 构造所有原生连接与关闭调用共用的目标设备参数。 + Map get _params => { + 'vendorId': vendorId, + 'productId': productId, + }; +} diff --git a/packages/drago_usb_printer/pubspec.yaml b/packages/drago_usb_printer/pubspec.yaml new file mode 100644 index 0000000..d90216c --- /dev/null +++ b/packages/drago_usb_printer/pubspec.yaml @@ -0,0 +1,19 @@ +name: drago_usb_printer +description: Project-owned Android USB transport with complete-write verification. +version: 0.1.2+zax.1 +publish_to: none + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=3.10.0" + +dependencies: + flutter: + sdk: flutter + +flutter: + plugin: + platforms: + android: + package: app.sks.client.drago_usb_printer + pluginClass: DragoUsbPrinterPlugin diff --git a/pubspec.lock b/pubspec.lock index e4f24b9..cf1fea1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -6,7 +6,7 @@ packages: description: name: _fe_analyzer_shared sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "76.0.0" _macros: @@ -19,39 +19,39 @@ packages: description: name: analyzer sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.11.0" android_id: dependency: "direct main" description: name: android_id - sha256: "748ba5f93dd5c497e675d8eaa1404346ce4d1794464ea654576ff192d153b92a" - url: "https://pub.flutter-io.cn" + sha256: f6489be4c4380cdb55a99db5b03764d936e84e9d5258a26571aa500bd1159fe0 + url: "https://pub.dev" source: hosted - version: "0.4.0" + version: "0.4.1" archive: dependency: transitive description: name: archive - sha256: "0c64e928dcbefddecd234205422bcfc2b5e6d31be0b86fef0d0dd48d7b4c9742" - url: "https://pub.flutter-io.cn" + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" source: hosted - version: "4.0.4" + version: "4.0.9" args: dependency: transitive description: name: args - sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 - url: "https://pub.flutter-io.cn" + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" source: hosted - version: "2.6.0" + version: "2.7.0" async: dependency: transitive description: name: async sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.11.0" background_downloader: @@ -59,7 +59,7 @@ packages: description: name: background_downloader sha256: "0873a939f5a301872cb7614e12f759fbccef2e3ed0d0d37b540dde7611275b12" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "7.12.3" barcode: @@ -67,23 +67,23 @@ packages: description: name: barcode sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.2.9" bidi: dependency: transitive description: name: bidi - sha256: "9a712c7ddf708f7c41b1923aa83648a3ed44cfd75b04f72d598c45e5be287f9d" - url: "https://pub.flutter-io.cn" + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.dev" source: hosted - version: "2.0.12" + version: "2.0.13" boolean_selector: dependency: transitive description: name: boolean_selector sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.1" build: @@ -91,7 +91,7 @@ packages: description: name: build sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.2" build_config: @@ -99,7 +99,7 @@ packages: description: name: build_config sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.2" build_daemon: @@ -107,7 +107,7 @@ packages: description: name: build_daemon sha256: "8e928697a82be082206edb0b9c99c5a4ad6bc31c9e9b8b2f291ae65cd4a25daa" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.0.4" build_resolvers: @@ -115,7 +115,7 @@ packages: description: name: build_resolvers sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.4" build_runner: @@ -123,7 +123,7 @@ packages: description: name: build_runner sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.14" build_runner_core: @@ -131,7 +131,7 @@ packages: description: name: build_runner_core sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "8.0.0" built_collection: @@ -139,23 +139,23 @@ packages: description: name: built_collection sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "5.1.1" built_value: dependency: transitive description: name: built_value - sha256: "8b158ab94ec6913e480dc3f752418348b5ae099eb75868b5f4775f0572999c61" - url: "https://pub.flutter-io.cn" + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 + url: "https://pub.dev" source: hosted - version: "8.9.4" + version: "8.13.0" cached_network_image: dependency: "direct main" description: name: cached_network_image sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.4.1" cached_network_image_platform_interface: @@ -163,7 +163,7 @@ packages: description: name: cached_network_image_platform_interface sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.1.1" cached_network_image_web: @@ -171,7 +171,7 @@ packages: description: name: cached_network_image_web sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.1" characters: @@ -179,7 +179,7 @@ packages: description: name: characters sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.0" charcode: @@ -187,7 +187,7 @@ packages: description: name: charcode sha256: fb0f1107cac15a5ea6ef0a6ef71a807b9e4267c713bb93e00e92d737cc8dbd8a - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.4.0" checked_yaml: @@ -195,7 +195,7 @@ packages: description: name: checked_yaml sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.0.3" clock: @@ -203,7 +203,7 @@ packages: description: name: clock sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.1" code_builder: @@ -211,7 +211,7 @@ packages: description: name: code_builder sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.10.1" collection: @@ -219,7 +219,7 @@ packages: description: name: collection sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.19.0" common_utils: @@ -227,47 +227,55 @@ packages: description: name: common_utils sha256: c26884339b13ff99b0739e56f4b02090c84054ed9dd3a045435cd24e7b99c2c1 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.0" connectivity_plus: dependency: "direct main" description: name: connectivity_plus - sha256: "04bf81bb0b77de31557b58d052b24b3eee33f09a6e7a8c68a3e247c7df19ec27" - url: "https://pub.flutter-io.cn" + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + url: "https://pub.dev" source: hosted - version: "6.1.3" + version: "6.1.5" connectivity_plus_platform_interface: dependency: transitive description: name: connectivity_plus_platform_interface - sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" - url: "https://pub.flutter-io.cn" + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.1.0" convert: dependency: transitive description: name: convert sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.1.2" + cron: + dependency: "direct main" + description: + name: cron + sha256: "2af3de2cabcc81346c00ff9db06296cdaf37ad17bd6f97fb63777a9ddefcacf3" + url: "https://pub.dev" + source: hosted + version: "0.6.2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" - url: "https://pub.flutter-io.cn" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" csslib: dependency: transitive description: name: csslib sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.2" cupertino_icons: @@ -275,7 +283,7 @@ packages: description: name: cupertino_icons sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.8" dart_style: @@ -283,39 +291,39 @@ packages: description: name: dart_style sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.3.8" data_table_2: dependency: "direct main" description: name: data_table_2 - sha256: "5a540a7b64809eb46b3809c67fc6faa29ca217864341294df66b1f0eb040c20e" - url: "https://pub.flutter-io.cn" + sha256: cb31d465dcf1e1598a662d06d3d2c16c73700ae9e837a3d91588f1dda7519abc + url: "https://pub.dev" source: hosted - version: "2.5.18" + version: "2.7.2" dbus: dependency: transitive description: name: dbus - sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" - url: "https://pub.flutter-io.cn" + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" decimal: dependency: transitive description: name: decimal - sha256: "4140a688f9e443e2f4de3a1162387bf25e1ac6d51e24c9da263f245210f41440" - url: "https://pub.flutter-io.cn" + sha256: "2c3c8b74f2948066d3f42585477aec9cfc48fefd7a723a4d4274a6c71a5c0df7" + url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.2.6" dio: dependency: "direct main" description: name: dio sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "5.7.0" dio_web_adapter: @@ -323,31 +331,30 @@ packages: description: name: dio_web_adapter sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.0.0" drago_pos_printer: dependency: "direct main" description: name: drago_pos_printer - sha256: "3ad14d15872204541a964005005a535ecf78f052a9d295b1703fddebc3e50def" - url: "https://pub.flutter-io.cn" + sha256: "8b601221dcf804f662228a6ae68d0c644543b6df0026e40808fc0fbf2c19871c" + url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.1.0" drago_usb_printer: - dependency: transitive + dependency: "direct overridden" description: - name: drago_usb_printer - sha256: "08272fe48797fe513cbdd57747dfe2edb2814cc438f9623311ec5e2601fcd1ab" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.1.2" + path: "packages/drago_usb_printer" + relative: true + source: path + version: "0.1.2+zax.1" ducafe_ui_core: dependency: "direct main" description: name: ducafe_ui_core sha256: "92ff9b05654e1b5ae2adbeac7227c669655da73f134bede75ad9aa1c505af904" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.6" dylib: @@ -355,23 +362,23 @@ packages: description: name: dylib sha256: bf609b3eb6492a3309b3d1dbe8f83a4031de5535dd7686be33487051cc760bb0 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.3.3" equatable: dependency: "direct main" description: name: equatable - sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" - url: "https://pub.flutter-io.cn" + sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" + url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.1.0" event_bus: dependency: transitive description: name: event_bus sha256: "1a55e97923769c286d295240048fc180e7b0768902c3c2e869fe059aafa15304" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.0.1" external_path: @@ -379,7 +386,7 @@ packages: description: name: external_path sha256: "68a18a2aa51ec012d7013ea2a80305dc5372f3577a2bbcc7dcc5550b25a5a73b" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.2.0" fake_async: @@ -387,7 +394,7 @@ packages: description: name: fake_async sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.1" ffi: @@ -395,7 +402,7 @@ packages: description: name: ffi sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.3" file: @@ -403,7 +410,7 @@ packages: description: name: file sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "7.0.1" fixnum: @@ -411,7 +418,7 @@ packages: description: name: fixnum sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.1" floor: @@ -419,7 +426,7 @@ packages: description: name: floor sha256: "52a8eac2c8d274e7c0c54251226f59786bb5b749365a2d8537d8095aa5132d92" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.4.2" floor_annotation: @@ -427,7 +434,7 @@ packages: description: name: floor_annotation sha256: a40949580a7ab0eee572686e2d3b1638fd6bd6a753e661d792ab4236b365b23b - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.5.0" floor_generator: @@ -435,7 +442,7 @@ packages: description: name: floor_generator sha256: "1499b3ab878a807e6fbe6f140dc014124845cd1df3090a113aae5fa7577a1e77" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.5.0" flutter: @@ -448,7 +455,7 @@ packages: description: name: flutter_cache_manager sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.4.1" flutter_easyloading: @@ -456,7 +463,7 @@ packages: description: name: flutter_easyloading sha256: ba21a3c883544e582f9cc455a4a0907556714e1e9cf0eababfcb600da191d17c - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.5" flutter_libserialport: @@ -464,7 +471,7 @@ packages: description: name: flutter_libserialport sha256: d193b5ac819db6540d20cc8d20d9f5ec9e3396edb4d2c4a23c97863fa9132336 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.5.0" flutter_lints: @@ -472,7 +479,7 @@ packages: description: name: flutter_lints sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "5.0.0" flutter_localizations: @@ -484,26 +491,26 @@ packages: dependency: transitive description: name: flutter_spinkit - sha256: d2696eed13732831414595b98863260e33e8882fc069ee80ec35d4ac9ddb0472 - url: "https://pub.flutter-io.cn" + sha256: "77850df57c00dc218bfe96071d576a8babec24cf58b2ed121c83cca4a2fdce7f" + url: "https://pub.dev" source: hosted - version: "5.2.1" + version: "5.2.2" flutter_subscreen_plugin: dependency: "direct main" description: name: flutter_subscreen_plugin sha256: b89bc8ba87956d441726557ece140ccea418ca4569e8f06e028e1017740ff6f3 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.2+5" flutter_svg: dependency: transitive description: name: flutter_svg - sha256: c200fd79c918a40c5cd50ea0877fa13f81bdaf6f0a5d3dbcc2a13e3285d6aa1b - url: "https://pub.flutter-io.cn" + sha256: cd57f7969b4679317c17af6fd16ee233c1e60a82ed209d8a475c54fd6fd6f845 + url: "https://pub.dev" source: hosted - version: "2.0.17" + version: "2.2.0" flutter_test: dependency: "direct dev" description: flutter @@ -514,7 +521,7 @@ packages: description: name: flutter_tts sha256: baa3cb6b4990318460fe28bfa8c7869399e97223971532c02bd97c5e876aa3c5 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.2.2" flutter_web_plugins: @@ -526,16 +533,16 @@ packages: dependency: "direct main" description: name: fluttertoast - sha256: "25e51620424d92d3db3832464774a6143b5053f15e382d8ffbfd40b6e795dcf1" - url: "https://pub.flutter-io.cn" + sha256: "90778fe0497fe3a09166e8cf2e0867310ff434b794526589e77ec03cf08ba8e8" + url: "https://pub.dev" source: hosted - version: "8.2.12" + version: "8.2.14" forui: dependency: "direct main" description: name: forui sha256: "3ebbf39d57f0667afc47cbab86b0afe679bba2173a51afc21855053663dc5062" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.9.1+1" forui_assets: @@ -543,7 +550,7 @@ packages: description: name: forui_assets sha256: df0f24b47ff0c3e16811bb4ad597b426b8e1a62fcf2fea7d95923ef4d6e8aa2c - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.9.1" frontend_server_client: @@ -551,7 +558,7 @@ packages: description: name: frontend_server_client sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.0.0" gbk_codec: @@ -559,39 +566,39 @@ packages: description: name: gbk_codec sha256: "3af5311fc9393115e3650ae6023862adf998051a804a08fb804f042724999f61" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.4.0" get: dependency: "direct main" description: name: get - sha256: c79eeb4339f1f3deffd9ec912f8a923834bec55f7b49c9e882b8fef2c139d425 - url: "https://pub.flutter-io.cn" + sha256: "5ed34a7925b85336e15d472cc4cfe7d9ebf4ab8e8b9f688585bf6b50f4c3d79a" + url: "https://pub.dev" source: hosted - version: "4.7.2" + version: "4.7.3" glob: dependency: transitive description: name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.flutter-io.cn" + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" + url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.0" google_fonts: dependency: transitive description: name: google_fonts - sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82 - url: "https://pub.flutter-io.cn" + sha256: df9763500dadba0155373e9cb44e202ce21bd9ed5de6bdbd05c5854e86839cb8 + url: "https://pub.dev" source: hosted - version: "6.2.1" + version: "6.3.0" graphs: dependency: transitive description: name: graphs sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.3.2" hex: @@ -599,31 +606,31 @@ packages: description: name: hex sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.2.0" html: dependency: transitive description: name: html - sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec" - url: "https://pub.flutter-io.cn" + sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" + url: "https://pub.dev" source: hosted - version: "0.15.5" + version: "0.15.7" http: dependency: "direct main" description: name: http - sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f - url: "https://pub.flutter-io.cn" + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.6.0" http_multi_server: dependency: transitive description: name: http_multi_server sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.2.2" http_parser: @@ -631,7 +638,7 @@ packages: description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.1.2" icons_launcher: @@ -639,39 +646,39 @@ packages: description: name: icons_launcher sha256: a7c83fbc837dc6f81944ef35c3756f533bb2aba32fcca5cbcdb2dbcd877d5ae9 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.0" image: dependency: "direct main" description: name: image - sha256: "13d3349ace88f12f4a0d175eb5c12dcdd39d35c4c109a8a13dfeb6d0bd9e31c3" - url: "https://pub.flutter-io.cn" + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" source: hosted - version: "4.5.3" + version: "4.5.4" intl: dependency: transitive description: name: intl sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.19.0" io: dependency: transitive description: name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.flutter-io.cn" + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" + url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.1.0" js: dependency: transitive description: name: js sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.7.1" json_annotation: @@ -679,7 +686,7 @@ packages: description: name: json_annotation sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.9.0" json_serializable: @@ -687,7 +694,7 @@ packages: description: name: json_serializable sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.8.0" leak_tracker: @@ -695,7 +702,7 @@ packages: description: name: leak_tracker sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "10.0.7" leak_tracker_flutter_testing: @@ -703,7 +710,7 @@ packages: description: name: leak_tracker_flutter_testing sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.8" leak_tracker_testing: @@ -711,7 +718,7 @@ packages: description: name: leak_tracker_testing sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.1" libserialport: @@ -719,7 +726,7 @@ packages: description: name: libserialport sha256: "392e1592def65282429832ec66fa25e9e163d3b37716b97691482e2406720727" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.3.0+1" lints: @@ -727,7 +734,7 @@ packages: description: name: lints sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "5.1.1" logger: @@ -735,7 +742,7 @@ packages: description: name: logger sha256: af05cc8714f356fd1f3888fb6741cbe9fbe25cdb6eedbab80e1a6db21047d4a4 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.3.0" logging: @@ -743,7 +750,7 @@ packages: description: name: logging sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.0" macros: @@ -751,7 +758,7 @@ packages: description: name: macros sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.1.3-main.0" matcher: @@ -759,7 +766,7 @@ packages: description: name: matcher sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.12.16+1" material_color_utilities: @@ -767,7 +774,7 @@ packages: description: name: material_color_utilities sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.11.1" meta: @@ -775,7 +782,7 @@ packages: description: name: meta sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.15.0" mime: @@ -783,7 +790,7 @@ packages: description: name: mime sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.6" mqtt_client: @@ -791,23 +798,23 @@ packages: description: name: mqtt_client sha256: "37aae360fac0b3322cb37267696c1535e1ad2984a13064d877273ffe4b16ca7a" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "10.5.1" network_info_plus: dependency: transitive description: name: network_info_plus - sha256: "08f4166bbb77da9e407edef6322a33f87b18c0ca46483fb25606cb3d2bfcdd2a" - url: "https://pub.flutter-io.cn" + sha256: f926b2ba86aa0086a0dfbb9e5072089bc213d854135c1712f1d29fc89ba3c877 + url: "https://pub.dev" source: hosted - version: "6.1.3" + version: "6.1.4" network_info_plus_platform_interface: dependency: transitive description: name: network_info_plus_platform_interface sha256: "7e7496a8a9d8136859b8881affc613c4a21304afeb6c324bcefc4bd0aff6b94b" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.0.2" nitrogen_flutter_svg: @@ -815,7 +822,7 @@ packages: description: name: nitrogen_flutter_svg sha256: "103313861b182fd4069bac896519b1ee64cbe1af0023d818f6c300b81c16853c" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.4.0" nitrogen_types: @@ -823,7 +830,7 @@ packages: description: name: nitrogen_types sha256: "5d4ec453aea14e34607bc6021ff1381192e8fcd8541bf103638baa4d013e668f" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.4.0+1" nm: @@ -831,7 +838,7 @@ packages: description: name: nm sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.5.0" number_pagination: @@ -839,7 +846,7 @@ packages: description: name: number_pagination sha256: "75d3a28616196e7c8df431d0fb7c48e811e462155f4cf3b5b4167b3408421327" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.6" octo_image: @@ -847,23 +854,39 @@ packages: description: name: octo_image sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.0" package_config: dependency: transitive description: name: package_config - sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" - url: "https://pub.flutter-io.cn" + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "67eae327b1b0faf761964a1d2e5d323c797f3799db0e85aa232db8d9e922bc35" + url: "https://pub.dev" + source: hosted + version: "8.2.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: name: path sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.9.0" path_parsing: @@ -871,7 +894,7 @@ packages: description: name: path_parsing sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.0" path_provider: @@ -879,23 +902,23 @@ packages: description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.5" path_provider_android: dependency: transitive description: name: path_provider_android - sha256: "4adf4fd5423ec60a29506c76581bc05854c55e3a0b72d35bb28d661c9686edf2" - url: "https://pub.flutter-io.cn" + sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 + url: "https://pub.dev" source: hosted - version: "2.2.15" + version: "2.2.17" path_provider_foundation: dependency: transitive description: name: path_provider_foundation sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.1" path_provider_linux: @@ -903,7 +926,7 @@ packages: description: name: path_provider_linux sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.2.1" path_provider_platform_interface: @@ -911,7 +934,7 @@ packages: description: name: path_provider_platform_interface sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.2" path_provider_windows: @@ -919,7 +942,7 @@ packages: description: name: path_provider_windows sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.3.0" pdf: @@ -927,7 +950,7 @@ packages: description: name: pdf sha256: "28eacad99bffcce2e05bba24e50153890ad0255294f4dd78a17075a2ba5c8416" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.11.3" pdf_widget_wrapper: @@ -935,7 +958,7 @@ packages: description: name: pdf_widget_wrapper sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.4" permission_handler: @@ -943,7 +966,7 @@ packages: description: name: permission_handler sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "11.4.0" permission_handler_android: @@ -951,47 +974,47 @@ packages: description: name: permission_handler_android sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "12.1.0" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: f84a188e79a35c687c132a0a0556c254747a08561e99ab933f12f6ca71ef3c98 - url: "https://pub.flutter-io.cn" + sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8 + url: "https://pub.dev" source: hosted - version: "9.4.6" + version: "9.6.1" permission_handler_html: dependency: transitive description: name: permission_handler_html - sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" - url: "https://pub.flutter-io.cn" + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" + url: "https://pub.dev" source: hosted - version: "0.1.3+5" + version: "0.1.4+1" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 - url: "https://pub.flutter-io.cn" + sha256: ed86a61c190258fdd65de395ea0632822e3415c1faec38eae0c31b479c28a531 + url: "https://pub.dev" source: hosted - version: "4.3.0" + version: "4.4.1" permission_handler_windows: dependency: transitive description: name: permission_handler_windows - sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" - url: "https://pub.flutter-io.cn" + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd + url: "https://pub.dev" source: hosted - version: "0.2.1" + version: "0.2.2" petitparser: dependency: transitive description: name: petitparser sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.0.2" pinyin: @@ -999,7 +1022,7 @@ packages: description: name: pinyin sha256: "240f271a3c71af20c8d2757756b5ee8e9d79a955d37abad4b3568fd406b22411" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.3.0" platform: @@ -1007,7 +1030,7 @@ packages: description: name: platform sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.1.6" plugin_platform_interface: @@ -1015,7 +1038,7 @@ packages: description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.8" pointycastle: @@ -1023,47 +1046,47 @@ packages: description: name: pointycastle sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.9.1" pool: dependency: transitive description: name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" - url: "https://pub.flutter-io.cn" + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" + url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.5.3" posix: dependency: transitive description: name: posix - sha256: a0117dc2167805aa9125b82eee515cc891819bac2f538c83646d355b16f58b9a - url: "https://pub.flutter-io.cn" + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" source: hosted - version: "6.0.1" + version: "6.5.2" printing: dependency: "direct main" description: name: printing - sha256: "482cd5a5196008f984bb43ed0e47cbfdca7373490b62f3b27b3299275bf22a93" - url: "https://pub.flutter-io.cn" + sha256: "689170c9ddb1bda85826466ba80378aa8993486d3c959a71cd7d2d80cb606692" + url: "https://pub.dev" source: hosted - version: "5.14.2" + version: "5.14.3" pub_semver: dependency: transitive description: name: pub_semver - sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" - url: "https://pub.flutter-io.cn" + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" + url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.2.1" pubspec_parse: dependency: transitive description: name: pubspec_parse sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.5.0" qr: @@ -1071,7 +1094,7 @@ packages: description: name: qr sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.2" qr_flutter: @@ -1079,7 +1102,7 @@ packages: description: name: qr_flutter sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.1.0" rational: @@ -1087,7 +1110,7 @@ packages: description: name: rational sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.2.3" rxdart: @@ -1095,7 +1118,7 @@ packages: description: name: rxdart sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.28.0" shelf: @@ -1103,7 +1126,7 @@ packages: description: name: shelf sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.4.2" shelf_web_socket: @@ -1111,7 +1134,7 @@ packages: description: name: shelf_web_socket sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.0.1" simple_sparse_list: @@ -1119,7 +1142,7 @@ packages: description: name: simple_sparse_list sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.1.4" sky_engine: @@ -1132,7 +1155,7 @@ packages: description: name: source_gen sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.5.0" source_helper: @@ -1140,7 +1163,7 @@ packages: description: name: source_helper sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.5" source_span: @@ -1148,23 +1171,15 @@ packages: description: name: source_span sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.10.0" - sprintf: - dependency: transitive - description: - name: sprintf - sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23" - url: "https://pub.flutter-io.cn" - source: hosted - version: "7.0.0" sqflite: dependency: "direct main" description: name: sqflite sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.1" sqflite_android: @@ -1172,7 +1187,7 @@ packages: description: name: sqflite_android sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.0" sqflite_common: @@ -1180,7 +1195,7 @@ packages: description: name: sqflite_common sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.5.4+6" sqflite_common_ffi: @@ -1188,7 +1203,7 @@ packages: description: name: sqflite_common_ffi sha256: "883dd810b2b49e6e8c3b980df1829ef550a94e3f87deab5d864917d27ca6bf36" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.3.4+4" sqflite_darwin: @@ -1196,7 +1211,7 @@ packages: description: name: sqflite_darwin sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.1+1" sqflite_platform_interface: @@ -1204,23 +1219,23 @@ packages: description: name: sqflite_platform_interface sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.0" sqlite3: dependency: transitive description: name: sqlite3 - sha256: "32b632dda27d664f85520093ed6f735ae5c49b5b75345afb8b19411bc59bb53d" - url: "https://pub.flutter-io.cn" + sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" + url: "https://pub.dev" source: hosted - version: "2.7.4" + version: "2.9.4" sqlparser: dependency: transitive description: name: sqlparser sha256: "91f47610aa54d8abf9d795a7b4e49b2a788f65d7493d5a68fbf180c3cbcc6f38" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.27.0" stack_trace: @@ -1228,7 +1243,7 @@ packages: description: name: stack_trace sha256: "9f47fd3630d76be3ab26f0ee06d213679aa425996925ff3feffdec504931c377" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.12.0" stream_channel: @@ -1236,23 +1251,23 @@ packages: description: name: stream_channel sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.2" stream_transform: dependency: transitive description: name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.flutter-io.cn" + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a + url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" string_scanner: dependency: transitive description: name: string_scanner sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.0" strings: @@ -1260,7 +1275,7 @@ packages: description: name: strings sha256: "482f1511d2cd8ab9f2c4cc148e6837be8a00c03b9a8eaedbc981a84d9c6305d8" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.2.0" sugar: @@ -1268,31 +1283,31 @@ packages: description: name: sugar sha256: a0a97f1d3552a3ef4cbec7c6e62c7b52c151cd5c11e984e8311700a918f73dc5 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.0.0" syncfusion_flutter_core: dependency: transitive description: name: syncfusion_flutter_core - sha256: "59b6d2a7deacade6129d2f15615ca49ed56278fea055cd2e52cace78a343dd5e" - url: "https://pub.flutter-io.cn" + sha256: f1d2b52697543e13bdefdc62d15868124a265987577f53224a7dbe176c8448f0 + url: "https://pub.dev" source: hosted - version: "28.2.11" + version: "28.2.12" syncfusion_flutter_datepicker: dependency: "direct main" description: name: syncfusion_flutter_datepicker - sha256: "7e598b8933d906ef7a3b8f47c59e942409f71c8e59e3d1b88e67acc9eea48817" - url: "https://pub.flutter-io.cn" + sha256: cfc91ebacee63b2c5220e541736f8df211d4f0bfbf34265778862ae20faf094f + url: "https://pub.dev" source: hosted - version: "28.2.11" + version: "28.2.12" synchronized: dependency: transitive description: name: synchronized sha256: "69fe30f3a8b04a0be0c15ae6490fc859a78ef4c43ae2dd5e8a623d45bfcf9225" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.3.0+3" term_glyph: @@ -1300,7 +1315,7 @@ packages: description: name: term_glyph sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.2.1" test_api: @@ -1308,7 +1323,7 @@ packages: description: name: test_api sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.7.3" timing: @@ -1316,7 +1331,7 @@ packages: description: name: timing sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.2" typed_data: @@ -1324,71 +1339,71 @@ packages: description: name: typed_data sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.4.0" unicode: dependency: transitive description: name: unicode - sha256: "0d99edbd2e74726bed2e4989713c8bec02e5581628e334d8c88c0271593fb402" - url: "https://pub.flutter-io.cn" + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 + url: "https://pub.dev" source: hosted - version: "1.1.8" + version: "1.1.9" universal_html: dependency: transitive description: name: universal_html - sha256: "56536254004e24d9d8cfdb7dbbf09b74cf8df96729f38a2f5c238163e3d58971" - url: "https://pub.flutter-io.cn" + sha256: c0bcae5c733c60f26c7dfc88b10b0fd27cbcc45cb7492311cdaa6067e21c9cd4 + url: "https://pub.dev" source: hosted - version: "2.2.4" + version: "2.3.0" universal_io: dependency: transitive description: name: universal_io - sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" - url: "https://pub.flutter-io.cn" + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.3.1" uuid: dependency: transitive description: name: uuid - sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff - url: "https://pub.flutter-io.cn" + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" source: hosted - version: "4.5.1" + version: "4.6.0" vector_graphics: dependency: transitive description: name: vector_graphics - sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de" - url: "https://pub.flutter-io.cn" + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" source: hosted - version: "1.1.18" + version: "1.1.19" vector_graphics_codec: dependency: transitive description: name: vector_graphics_codec sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.13" vector_graphics_compiler: dependency: transitive description: name: vector_graphics_compiler - sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad" - url: "https://pub.flutter-io.cn" + sha256: ca81fdfaf62a5ab45d7296614aea108d2c7d0efca8393e96174bf4d51e6725b0 + url: "https://pub.dev" source: hosted - version: "1.1.16" + version: "1.1.18" vector_math: dependency: transitive description: name: vector_math sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.4" vm_service: @@ -1396,47 +1411,47 @@ packages: description: name: vm_service sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "14.3.0" watcher: dependency: transitive description: name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" - url: "https://pub.flutter-io.cn" + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.1" web: dependency: transitive description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.1" web_socket: dependency: transitive description: name: web_socket - sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" - url: "https://pub.flutter-io.cn" + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" source: hosted - version: "0.1.6" + version: "1.0.1" web_socket_channel: dependency: transitive description: name: web_socket_channel - sha256: "0b8e2457400d8a859b7b2030786835a28a8e80836ef64402abef392ff4f1d0e5" - url: "https://pub.flutter-io.cn" + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" source: hosted - version: "3.0.2" + version: "3.0.3" win32: dependency: transitive description: name: win32 sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "5.10.1" xdg_directories: @@ -1444,7 +1459,7 @@ packages: description: name: xdg_directories sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.0" xml: @@ -1452,17 +1467,17 @@ packages: description: name: xml sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.5.0" yaml: dependency: transitive description: name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.flutter-io.cn" + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea + url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" sdks: dart: ">=3.6.0 <4.0.0" flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index ef4223a..5bb0076 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: pj_supermarket_cloud description: "智慧后勤-测试" publish_to: "none" -version: 1.1.1+1 +version: 2.0.3+203 environment: sdk: ^3.6.0 @@ -36,8 +36,8 @@ dependencies: number_pagination: ^1.1.6 # 日期选择 syncfusion_flutter_datepicker: ^28.2.11 - # tts转语音 - flutter_tts: ^4.2.2 + # tts转语音(4.2.5 的 Kotlin 代码需要更新的 Kotlin 编译器,当前 AGP 环境钉在 4.2.2) + flutter_tts: 4.2.2 # 二维码 qr_flutter: ^4.1.0 @@ -75,6 +75,12 @@ dependencies: printing: ^5.13.4 # 后台下载(兼容当前AGP 8.1) background_downloader: ^7.12.3 + # 定时任务 cron 调度 + cron: 0.6.2 + # 摘要算法(终端日志上传去重) + crypto: ^3.0.6 + # 应用包信息(自动更新/日志上传) + package_info_plus: 8.2.1 dev_dependencies: flutter_test: sdk: flutter @@ -88,6 +94,12 @@ dev_dependencies: # json json_serializable: 6.8.0 +# drago_pos_printer 通过该传递依赖写入 Android USB;pub.dev 版本的 +# build.gradle 使用了当前 AGP 不支持的 kotlin() 写法,随项目携带修复版。 +dependency_overrides: + drago_usb_printer: + path: packages/drago_usb_printer + flutter: uses-material-design: true