Author SHA1 Message Date
mazengfei 8fcbbb38f6 refactor(api): 优化餐盘柜接口路径和数据模型
- 删除密码登录相关代码和布局资源,简化登录流程
- 修改 ApiService 接口路径由旧项目旧路径切换为新 nutrition 模块路径
- 修改设备用户信息 EquipmentUserInfo 中 id 类型由 Long 改为 String,防止精度丢失
- 重构 RemoteRepository 中绑定和解绑接口,适配新参数结构
- 优化 UserViewModel 中获取用户信息接口,添加静默请求支持
- 改进 DiagnosticExporter,优先导出到 U 盘,不可用时回落到应用目录
- UsbStorageHelper 增强 U 盘识别算法,结合路径与文件系统类型双重校验
- 移除无用的 LoginParam 请求模型以及密码登录相关引用
- 调整网络请求相关的导入语句,清理多余依赖
- 修正设备初始化 ActiveKey 的使用方式,恢复为后台下发值
- BindPlateFragment 中绑定失败提示改为“绑定成功,开柜失败”
- OpsActivity 导出诊断包按钮弹窗改为 AlertDialog 显示结果信息
- SearchParam 默认分页参数改为 pageNum=1,pageSize=10
- 修改 SettingViewModel.unbindPlate 参数类型为 String,统一接口调用参数格式
2026-09-09 15:57:44 +08:00
mazengfei 8bcc5a0289 feat(ops): 增加运维面板及诊断导出功能
- 添加运维面板OpsActivity及对应布局,展示设备状态、网络、MQTT、人脸数据等信息
- 实现诊断包导出工具DiagnosticExporter,支持将设备信息和日志导出到U盘
- 新增设备健康信息获取工具DeviceInfoProvider,提供运行时长、内存和存储信息
- 实现运行时日志写入文件FileLoggingTree,支持按天滚动、切分与过期清理
- 增加日志文件管理LogFileManager,支持列出、读取尾部和清理日志文件
- 丰富MQTT管理器MqttManager,记录连接状态、错误、连接次数等详情并支持手动重连
- 为环境切换引入EnvironmentSwitcher,实现基础地址切换和清空本地人脸库后自动重启
- 扩展ArcFace人脸库接口,支持查询最近更新人脸及按userId精确搜索
- MainActivity增加运维面板入口,管理员列表页右上角双击时间触发打开
- 应用启动时种植FileLoggingTree,确保运行日志同时输出至文件和控制台
2026-09-02 17:47:24 +08:00
mazengfei 994bcf5bc2 feat(face): 优化人脸库更新同步及识别引擎刷新机制
- 引入进程级互斥锁 FaceSyncLock,串行化 MQTT 实时更新与 HTTP 增量轮询人脸库写操作,避免重复入库
- 优化 MQTT 实时消息处理逻辑,批内去重并加入乱序守卫,确保数据正确性与一致性
- 将识别引擎内存刷新调度防抖至 scheduleFaceRefresh,合并短时间内多次变更减少资源消耗
- 在 BaseActivity 中实现防抖刷新机制,统一 MQTT、HTTP增量和定时轮询三路触发入口
- 移除应用启动时人脸 MQTT 订阅初始化,改为首次全量同步完成后启动,防止并发竞态
- LoginByFaceActivity 和 DeviceInitActivity 适配新增机制,优化人脸数据同步及识别引擎刷新流程
2026-09-02 15:12:23 +08:00
mazengfei 1bd57e0680 feat(mqtt): 增加人脸库 MQTT 实时变更同步功能
- 新增 MQTT 客户端依赖及配置,实现设备端实时接收人脸变化推送
- 实现 FaceMqttSubscriber 单例,启动时解析环境选择对应 MQTT 配置
- 实现消息解析、乱序过滤及本地人脸库数据实时更新逻辑
- 设备端订阅人脸变更主题,连接/重连成功触发 HTTP 增量补拉兜底机制
- 在 LoginByFaceActivity 中订阅 MQTT 事件,实时刷新识别引擎内存
- MQTT 连接管理器 MqttManager 负责连接、重连、订阅及消息分发,实现完整生命周期管理
- 新增环境选择弹窗,优化初始化界面布局和按钮样式
- 配置激活码硬编码修正,避免测试环境激活码错误导致的问题
- 日志与错误提示等细节优化,提升稳定性与用户体验
2026-09-02 14:57:35 +08:00
mazengfei 04b0c85482 refactor(login): 优化人脸登录与设备初始化环境切换逻辑
- 移除DeviceInitActivity中的多点触控环境切换功能及相关依赖,简化代码结构
- 调整登录页连点触发环境切换逻辑,新增环境弹窗显示及倒计时暂停机制
- 实现登录页环境切换后自动重启应用,提升用户体验和状态一致性
- 修改NetViewModelV2分页大小从100至500,提高批量请求效率
- 注释MyApp中deviceId硬编码,恢复动态获取设备ID
- 清理UserViewModel中已废弃的人脸引擎激活代码,保持代码整洁
2026-09-02 11:11:34 +08:00
mazengfei 57345f8750 feat(environment): 新增环境切换功能及相关优化
- 新增业务环境选择弹窗 EnvironmentSelectDialog,支持本地/测试/生产环境切换
- 实现连点三次屏幕触发环境切换弹窗功能,便于快速切换接口环境
- 切换环境时清空本地人脸库并重置增量同步状态,保证环境切换的完整生效
- 业务全局 BaseUrl 支持从持久化读取,首次无值时默认测试环境
- Retrofit Client 接口 BaseUrl 优化,自动规范尾部斜杠,保证 BaseUrl 合法
- 设备初始化界面增强,增加等待框触摸穿透,优化接口请求失败时的提示信息
- 提升人脸缓存拉取接口分页大小,默认请求 500 条数据
- 修正登录界面布局细节,隐藏密码切换按钮,调整控件顺序及样式细节
2026-09-02 09:59:48 +08:00
mazengfeiandClaude Fable 5 d33f941d12 docs: 补充切环境后 ArcSoft 参数自动适配说明
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 17:29:53 +08:00
mazengfeiandClaude Fable 5 41cfec0583 docs: 新增业务 BaseUrl 切换设计文档
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 17:15:04 +08:00
mazengfei d53ea8f798 feat(api): 新增V2版本接口支持并集成至网络请求客户端
- 新增 ApiServiceV2 接口,支持人脸缓存、增量数据、设备配置等接口
- ApiClient 中新增 apiServiceV2 对象,复用 Retrofit 实例,调整超时时间至60秒
- DeviceInitActivity 改用 netViewModelV2 进行人脸缓存全量拉取及设备配置读取
- BaseActivity 新增对人脸增量同步接口调用,调用成功后刷新本地识别缓存
- BaseActivity 新增清空本地人脸库功能,异步清理数据库并刷新识别缓存
- FaceApi 增加清空本地人脸库接口,重写数据库操作逻辑
- 升级 lib_face 模块 Room 数据库版本,新增字段以支持多标识人脸实体扩展
- FaceEntity 实体扩展会员编号、用户ID、人脸ID、会员标识、更新时间等字段
- FaceDao 新增按人脸ID查询和删除接口,支持多重人脸数据操作
- 优化网络层 OkHttpClient 配置,简化拦截器写法及日志级别判断
- 升级 lib_face 模块支持 arm64-v8a 架构,增强兼容性
- 规范模块间依赖关系,统一版本管理及包路径声明
- 完善 CLAUDE.md 文档,补充项目架构、模块划分与开发流程说明
2026-08-18 17:14:06 +08:00
mazengfei 5b8db65fa2 优化 2026-03-02 16:35:36 +08:00
lvmeng 147fa08e9b 会员版修改包名 2026-03-02 15:23:52 +08:00
96 changed files with 4870 additions and 655 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="azul-17" />
<option name="gradleJvm" value="jbr-21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
+2 -1
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="zulu-17" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
+55
View File
@@ -0,0 +1,55 @@
# Repository Guidelines
本项目 **SmartPlateCabinet / SmartTakePlate(发盘机)** 是基于 Android 的智能餐盘柜控制系统,集成人脸识别(ArcFace SDK)与 PLC 硬件控制。本文档为贡献者提供统一规范。
## 项目结构与模块组织
多模块 Gradle 工程,模块定义于 `settings.gradle.kts`
- `app/` — 主应用模块,命名空间 `com.sw.platecabinet`applicationId `com.sw.take.plate`)。源码位于 `app/src/main/java/com/sw/platecabinet/`,按职责分包:`activity``fragment``viewmodel``repository``network``api`/`interceptor`/`task`)、`model``request`/`response`)、`socket``utils`PLC 实现在 `utils/mego`)、`adapter``dialog``view``receiver``ext`
- `lib_face/` — 人脸识别库模块(`com.sw.plate.utils.arcface`),封装 ArcFace SDK,含 `camera``face``facedb`Room 持久化)、`faceserver``viewmodel` 等。
- 依赖版本统一管理于 `gradle/libs.versions.toml`;本地 AAR 依赖置于 `app/libs/`(如 PLC SDK `plc_aar_104.aar`)。
- 测试目录:`app/src/test`(单元测试)、`app/src/androidTest`(仪器测试)。
## 构建、测试与开发命令
```bash
./gradlew assembleDebug # 编译 Debug APK
./gradlew installDebug # 安装到已连接设备
./gradlew clean # 清理构建产物
./gradlew :app:test # 运行单元测试
./gradlew :app:connectedAndroidTest # 运行仪器测试(需连接设备)
```
修改 Kotlin/Java 或资源后,须运行 `./gradlew assembleDebug` 验证编译通过。
## 编码规范与命名约定
- 语言:Kotlin 优先,Java 仅限历史代码维护;JVM target 11。
- 架构:MVVM。Activity 继承 `BaseActivity<VB>`Fragment 继承 `BaseFragment<VB>`ViewModel 继承 `BaseViewModel`Repository 继承 `BaseRepository`
- 命名:`XxxActivity` / `XxxFragment` / `XxxViewModel` / `XxxRepository`;工具类 `XxxUtil``XxxHelper`
- 异步:协程(`viewModelScope.launch`);PLC 操作走回调 `PlcCallback<T>`;网络请求在 ViewModel 内用 `launchRequest { }`
- 资源:布局 `activity_*` / `fragment_*`,字符串禁止硬编码到布局;依赖版本禁止硬编码于 `build.gradle.kts`,统一使用版本目录。
- 注释统一使用简体中文;标识符保持英文。
## 测试指南
框架:JUnit 4 + AndroidX TestEspresso)。业务逻辑优先在 `src/test` 编写单元测试;涉及 UI 或硬件交互的改动,须说明是否需仪器测试。测试类置于对应包路径下。
## 提交与分支约定
提交信息采用 **Conventional Commits(中文描述)**`<type>(<scope>): <中文描述>`。常见 type`feat``fix``refactor`scope 如 `login``plc``face``ui``build``api``base`。示例:
```
feat(plc): 添加餐盘平台复位功能和无餐盘提示界面
fix(login): 修复人脸识别登录界面返回时串口连接超时问题
```
分支:`main` 为基础分支;功能分支命名形如 `main_<功能>_<YYMMDD>`(如 `main_吐盘机_260701_5.0`)。合并请求需描述变更内容、关联问题;改动 UI 或硬件交互时附截图或验证说明。
## 配置与安全提示
- 签名:Debug 使用 `app/swkey.jks`(见 `app/build.gradle.kts`)。
- 明文 HTTP 流量已通过 `network_security_config.xml` 放行,请勿在受控环境外泄露配置。
- `app` 依赖 `lib_face` 时已排除其中的 `android-serialport`,由 app 自行引入,避免重复依赖。
- ABI 过滤:`armeabi-v7a``arm64-v8a`
+198
View File
@@ -0,0 +1,198 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目概述
SmartTakePlate(发盘机)是一个基于 Android 的智能餐盘柜控制系统,集成了人脸识别和 PLC 硬件控制功能。
**应用ID**: com.sw.platecabinet.member(会员版;命名空间 com.sw.platecabinet
**最低SDK**: 24 (Android 7.0)
**目标SDK**: 35
## 构建与运行
### 构建项目
```bash
./gradlew assembleDebug
```
### 安装到设备
```bash
./gradlew installDebug
```
### 清理构建
```bash
./gradlew clean
```
**注意**: 构建需要 Java 17 环境(compileOptions 为 Java 11,但 Gradle 需 JDK 17)。直接使用系统默认 JDK 11 会报错,需使用 Android Studio 自带的 JBR 设置 `JAVA_HOME` 后再执行 gradlew。
## 项目架构
### 模块结构
- `app/` - 主应用模块(PLC控制、业务逻辑)
- `lib_face/` - 人脸识别库模块(ArcFace SDK封装)
### 架构模式
采用 MVVM 架构:
- **View**: Activity/Fragment(使用 ViewBinding
- **ViewModel**: 继承自 `BaseViewModel`,处理业务逻辑和状态管理
- **Repository**: `BaseRepository``RemoteRepository` 处理数据层
### 关键技术栈
- Kotlin 2.0.21 + Coroutines
- Retrofit 3.0.0 + OkHttp 4.12.0(网络请求)
- Room 2.2.5(人脸数据本地存储)
- CameraX 1.3.0(摄像头)
- EventBus 3.3.1(事件总线)
- PLC SDK (plc_aar_104.aar)(硬件控制)
- ArcSoft 人脸识别 SDK
## 核心功能模块
### 1. 串口硬件控制(柜锁)
**核心包**: `lib_face/com.sw.plate.utils.comn/`
关键类:
- `SerialApi` - 对外 API 门面(静态方法),串口路径 `/dev/ttyS2`、波特率 19200
- `init()` - 打开串口连接
- `openPlate(boxNumber, callback)` - 开柜(命令由 `CabinetLockCommand.generateOpenCommand` 生成)
- `close()` - 关闭串口
- `SerialPortManager` - 串口管理(发送命令、接收线程 `SerialReadThread`
- `CabinetLockCommand` - 柜锁命令生成
**调用方**: `BaseActivity``BindPlateFragment``SettingListFragment` 直接调用 `SerialApi`
**注意**: 所有串口操作都是异步的,通过 `SerialPortManager.SendCallback` 回调返回结果。
### 2. 人脸识别
**核心包**: `lib_face/com.sw.plate.utils.arcface/`
关键类:
- `FaceApi` - 人脸操作 API
- `RecognizeViewModel` - 人脸识别 ViewModel
- `FaceDatabase` - Room 数据库(存储人脸特征)
- `CameraHelper` / `DualCameraHelper` - 摄像头管理
人脸数据流程:
1. 从服务器获取用户人脸数据(`UserViewModel.getUserFaceList()`
2. 存储到本地 Room 数据库
3. 实时识别时与本地数据库对比
### 3. 网络层
**API 配置**: `GlobalData.appBaseUrl`(当前指向 `https://dev.yixiong-tech.com:8081`,切换环境直接修改该字段)
**API 响应格式**: `ApiResponse<T>`
- 成功: `code == "00000"`
- 失败: 检查 `msg` 字段
**网络客户端**: `ApiClient`Retrofit 单例)
**API 接口**: `ApiService`
### 4. 全局配置
**GlobalData** 包含关键配置:
- `arrayCross` = 2(横排数量)
- `arrayVertical` = 11(竖排数量)
- `arrayMode` = 0(0=竖直排列, 1=水平排列)
- ArcSoft SDK 配置:`appId`, `sdkKey`, `activeKey`
**SharedPreferences 键**: 见 `GlobalKey`
## 应用启动流程
1. `MyApp.onCreate()` - 初始化日志、崩溃处理、全局数据
2. `DeviceInitActivity` - 加载人脸缓存、获取设备配置
3. `LoginByFaceActivity` - 人脸识别登录
4. `MainActivity` - 主界面(Fragment 容器)
## 代码规范
### 命名约定
- Activity: `XxxActivity`
- Fragment: `XxxFragment`
- ViewModel: `XxxViewModel`
- Repository: `XxxRepository`
- 工具类: `XxxUtil``XxxHelper`
### 基类使用
- Activity 继承 `BaseActivity<VB>`VB 为 ViewBinding 类型)
- Fragment 继承 `BaseFragment<VB>`
- ViewModel 继承 `BaseViewModel`
- Repository 继承 `BaseRepository`
### 异步操作
- 使用 Kotlin Coroutines`viewModelScope.launch`
- PLC 操作使用回调(`PlcCallback<T>`
- 网络请求在 ViewModel 中调用 `launchRequest { }`
## 重要注意事项
### NDK 架构
项目仅支持 **armeabi-v7a**32位 ARM),不支持 arm64-v8a。这是因为 PLC SDK 和 ArcSoft SDK 的限制。
### 硬件依赖
- 需要串口通信支持(android-serialport
- 需要摄像头权限(人脸识别)
- 需要网络权限(API 调用)
### 调试配置
- 签名密钥: `swkey.jks`(密码: 123456
- 允许明文 HTTP 流量(`network_security_config.xml`
- ProGuard 混淆已禁用
### lib_face 依赖
app 模块依赖 lib_face 时,**排除了 android-serialport 依赖**,因为 app 模块自己引入了该库。
## 常用工具类
- `SpTool` - SharedPreferences 操作
- `Debouncer` - 防抖
- `QRCodeUtil` - 二维码生成
- `PermissionHelper` - 权限管理
- `FragmentHelper` - Fragment 管理
## Git 分支策略
- 主分支: `main`
- 功能分支命名形如 `main_<功能>_<YYMMDD>`(如 `main_吐盘机_260327`
## 最近开发重点
根据最近的提交记录,项目正在进行以下工作:
1. 会员版(已修改包名)
2. 绑定/解绑餐盘功能
3. 人脸识别登录(含手机号密码登录)
## API 调用示例
```kotlin
// 在 ViewModel 中
launchRequest(
request = { repository.someApiCall(params) },
onSuccess = { data ->
// 处理成功响应
},
onError = { code, msg ->
// 处理错误
}
)
```
## 串口开柜示例
```java
// 串口操作为异步回调(SerialPortManager.SendCallback
SerialApi.openPlate(boxNumber, new SerialPortManager.SendCallback() {
@Override
public void onSuccess() {
// 开柜命令发送成功
}
@Override
public void onFail(Exception e) {
// 开柜失败
}
});
```
+5 -2
View File
@@ -14,11 +14,11 @@ android {
keyPassword = "123456"
}
}
namespace = "com.sw.platecabinet"
namespace = "com.sw.platecabinet.member"
compileSdk = 35
defaultConfig {
applicationId = "com.sw.platecabinet"
applicationId = "com.sw.platecabinet.member"
minSdk = 24
targetSdk = 35
versionCode = 1
@@ -100,4 +100,7 @@ dependencies {
implementation(libs.android.core)
implementation("org.greenrobot:eventbus:3.3.1")
// MQTT 客户端(人脸变更实时推送)
implementation(libs.paho.mqtt)
}
Binary file not shown.
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.sw.take.plate",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.1",
"outputFile": "zncpg_1.1-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/zncpg_1.1-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/zncpg_1.1-release.dm"
]
}
],
"minSdkVersionForDexing": 24
}
+11 -11
View File
@@ -17,7 +17,7 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<application
android:name=".MyApp"
android:name="com.sw.platecabinet.MyApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
@@ -31,7 +31,7 @@
tools:targetApi="31">
<!-- 注册BootReceiver,监听开机完成广播 -->
<receiver
android:name=".receiver.BootReceiver"
android:name="com.sw.platecabinet.receiver.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
@@ -39,24 +39,20 @@
</intent-filter>
</receiver>
<activity
android:name=".activity.UnBindDialogActivity"
android:name="com.sw.platecabinet.activity.UnBindDialogActivity"
android:exported="false"
android:theme="@style/DialogActivity" />
<activity
android:name=".activity.InitActivity"
android:name="com.sw.platecabinet.activity.InitActivity"
android:exported="false"
android:launchMode="singleTask">
</activity>
<activity
android:name=".activity.LoginByPwdActivity"
android:exported="false"
android:launchMode="singleTask"/>
<activity
android:name=".activity.LoginByFaceActivity"
android:name="com.sw.platecabinet.activity.LoginByFaceActivity"
android:exported="true"
android:launchMode="singleTask"/>
<activity
android:name=".activity.DeviceInitActivity"
android:name="com.sw.platecabinet.activity.DeviceInitActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
@@ -66,7 +62,11 @@
</intent-filter>
</activity>
<activity
android:name=".activity.MainActivity"
android:name="com.sw.platecabinet.activity.MainActivity"
android:exported="false">
</activity>
<activity
android:name="com.sw.platecabinet.activity.OpsActivity"
android:exported="false">
</activity>
</application>
@@ -21,6 +21,7 @@ object GlobalData {
* 竖排数量
*/
var arrayVertical: Int = 11
/**
* 排列方式 0 垂直 1 水平
*/
@@ -28,12 +29,19 @@ object GlobalData {
var appId = "Hkz1rBk6PZXbS8KwKr67K2eZtsz8bRoMHLg64bUdgCZj"
var sdkKey = "AabXs3sHM8UhhE7oCGf4LVMLrdkGb1nJNksbjjTdVt7k"
var activeKey ="085F-118G-Q3J6-35UX" //"085F-118G-Q391-53YL"
var activeKey = "85Q1-1216-X3DH-QYTJ"//"085F-118G-Q3J6-35UX" //"085F-118G-Q391-53YL"
/**
* 具体业务baseurl
*/
var appBaseUrl: String = "https://dev.yixiong-tech.com:8081"
var appBaseUrl: String = ""
/**
* 具体业务 BaseUrl
*/
const val LOCAL_BASE_URL: String = "http://192.168.10.101:24801"//开发环境
const val TEST_BASE_URL: String = "https://dev.yixiong-tech.com:8081"//测试环境
const val PROD_BASE_URL: String = "https://platform-api.uat.shuziweidao.com"//生产环境
/**
* 设备id
@@ -61,4 +69,24 @@ object GlobalKey {
const val KEY_FIRST_RUN = "firstRun"
const val KEY_USER_INFO = "userInfoKey"
const val PARAM_EQUIPMENT_INFO = "equipmentUserInfo"
}
const val KEY_BASE_URL = "baseUrlKey"
}
/**
* 业务环境配置项
* @param name 环境名称(用于弹窗展示)
* @param url 环境 BaseUrl(不带尾斜杠)
*/
data class Environment(
val name: String,
val url: String
)
/**
* 预设环境列表(本地 / 测试 / 生产)
*/
val ENVIRONMENTS: List<Environment> = listOf(
Environment("本地", GlobalData.LOCAL_BASE_URL),
Environment("测试", GlobalData.TEST_BASE_URL),
Environment("生产", GlobalData.PROD_BASE_URL)
)
+10 -1
View File
@@ -4,6 +4,8 @@ import android.util.Log
import com.sw.plate.App
import com.sw.plate.utils.AppUtil
import com.sw.platecabinet.utils.CrashHandler
import com.sw.platecabinet.utils.FileLoggingTree
import com.sw.platecabinet.utils.SpTool
import timber.log.Timber
class MyApp : App() {
@@ -14,6 +16,8 @@ class MyApp : App() {
override fun onCreate() {
super.onCreate()
Timber.plant(Timber.DebugTree())
// 运行时日志落盘(供运维页查看/导出),与 DebugTree 并存
Timber.plant(FileLoggingTree(this))
Timber.d("初始化")
initGlobalData()
@@ -26,13 +30,18 @@ class MyApp : App() {
*/
private fun initGlobalData() {
var deviceId = AppUtil.getUDID(this)
deviceId = "be154831-3466-3ba2-a2ea-57652c919fed"
// deviceId = "be154831-3466-3ba2-a2ea-57652c919fed"
// deviceId="2987f0c5-5754-33e9-b00a-251db5e2e55f"
Log.d("MyApp", "initialize: deviceId=$deviceId")
GlobalData.deviceId = deviceId
GlobalData.appVersion = AppUtil.getAppVersionCode(this).toString()
GlobalData.globalEquipmentCode = "202501171634"
// 从持久化读取业务 BaseUrl,首次无值兜底测试环境
GlobalData.appBaseUrl = SpTool.getBaseUrl().ifBlank { GlobalData.TEST_BASE_URL }
Timber.d("initGlobalData appBaseUrl=${GlobalData.appBaseUrl}")
// GlobalData.appBaseUrl = "http://192.168.1.201:14801"
}
}
@@ -4,6 +4,8 @@ import android.app.Dialog
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.text.TextUtils
import android.view.KeyEvent
import android.view.View
@@ -17,30 +19,29 @@ import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding
import com.sw.inbound.utils.DateTimeUtils
import com.sw.plate.utils.Base64
import com.sw.plate.utils.ScanGunKeyEventHelper
import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.arcface.facedb.FaceDatabase
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import com.sw.plate.utils.arcface.viewmodel.RecognizeViewModel
import com.sw.plate.utils.comn.SerialApi
import com.sw.plate.utils.comn.SerialPortManager
import com.sw.platecabinet.R
import com.sw.platecabinet.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.member.R
import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.dialog.BalanceNotEnoughDialog
import com.sw.platecabinet.ext.setClickListeners
import com.sw.platecabinet.model.response.EquipmentUserInfo
import com.sw.platecabinet.model.response.UserFaceModel
import com.sw.platecabinet.utils.IntervalExecutor
import com.sw.platecabinet.utils.PermissionHelper
import com.sw.platecabinet.utils.SpTool
import com.sw.platecabinet.view.CustomLoadingDialog
import com.sw.platecabinet.viewmodel.NetViewModelV2
import com.sw.platecabinet.viewmodel.SettingViewModel
import com.sw.platecabinet.viewmodel.UserViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
/**
@@ -61,6 +62,9 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
// 用户对应的viewModel
protected val viewModel by viewModels<UserViewModel>()
// V2 网络请求 viewModel(人脸缓存/增量同步/设备配置)
val netViewModelV2 by viewModels<NetViewModelV2>()
private var startTime: Long = 0
override fun onCreate(savedInstanceState: Bundle?) {
@@ -84,8 +88,6 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
registerKeyEvent()
val durationTime = System.currentTimeMillis() - startTime
Timber.d("启动时间:$durationTime")
startFaceTask()
}
/**
@@ -394,9 +396,24 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
// private val initialDelay = 5 * 60 * 1000L
// private val dealyMillis = 10 * 60 * 1000L
private val initialDelay = 1 * 60 * 1000L
private val dealyMillis = 1 * 60 * 1000L
private var taskPageNo = 1
private val initialDelay = 5 * 60 * 1000L
private val dealyMillis = 5 * 60 * 1000L
val recognizeViewModel by viewModels<RecognizeViewModel>()
/**
* 引擎内存刷新防抖:合并短时间内连续的人脸变更(MQTT 实时 + HTTP 增量补拉 + 定时轮询),
* 避免多次全量重载 ArcSoft 引擎内存(removeFaceFeature(-1) + registerFaceFeature 非原子)。
*/
private val faceRefreshHandler = Handler(Looper.getMainLooper())
private val faceRefreshRunnable = Runnable { recognizeViewModel.refreshFaceList() }
/** 防抖调度引擎内存刷新(2s 内连续变更合并为一次) */
protected fun scheduleFaceRefresh() {
faceRefreshHandler.removeCallbacks(faceRefreshRunnable)
faceRefreshHandler.postDelayed(faceRefreshRunnable, 2000)
}
fun startFaceTask() {
faceTaskJob =
intervalExecutor.startIntervalTaskWithInitialDelay(initialDelay, dealyMillis) {
@@ -404,70 +421,33 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
}
}
private var faceTimestamp = 0L
private fun getFaceIncrementList() {
/**
* 人脸增量同步(V2,同步逻辑在 NetViewModelV2 内部处理)
*/
private fun getFaceIncrementList(pageNo: Int = 1) {
val timestamp = SpTool.getLastFaceTimestamp()
if (timestamp == 0L) {
return
}
settingViewModel.getFaceIncrementList(
pageNo = taskPageNo,
netViewModelV2.getFaceIncrementList(
pageNo = pageNo,
timestamp = timestamp
) { list ->
runOnUiThread {
if (taskPageNo == 1 && list.isEmpty()) {
//未查询到增量数据
return@runOnUiThread
}
updateFaceData(list)
if (list.size >= settingViewModel.PAGE_SIZE) {
faceTimestamp = list.last().faceUpdateTimestamp?:0
taskPageNo++
getFaceIncrementList()
return@runOnUiThread
}
if (list.isNotEmpty()) {
faceTimestamp = list.last().faceUpdateTimestamp?:0
}
SpTool.setLastFaceTimestamp(faceTimestamp)
}
) {
scheduleFaceRefresh()
}
}
private val recognizeViewModel by viewModels<RecognizeViewModel>()
private fun updateFaceData(list: List<UserFaceModel>) {
Thread {
val faceList = mutableListOf<FaceEntity>()
try {
list.forEach { model ->
if (model.faceDeleted == true) {
//删除数据
FaceDatabase.getInstance(this).faceDao().deleteFaceById(model.userId)
} else {
//保存数据
val faceEntity = FaceEntity(
model.userId,
null,
Base64.decode(model.faceFeatureStr)
).also {
it.userType = "1"
}
faceList.add(faceEntity)
}
}
} catch (e: Exception) {
e.printStackTrace()
}
try {
if (faceList.isNotEmpty()) {
FaceDatabase.getInstance(this).faceDao().insert(faceList)
}
recognizeViewModel.refreshFaceList()
} catch (e: Exception) {
e.printStackTrace()
}
}.start()
/**
* 清空本地人脸库
*/
fun clearAllFace(block: () -> Unit) {
lifecycleScope.launch(Dispatchers.IO) {
val faceDao = FaceDatabase.getInstance(this@BaseActivity).faceDao()
faceDao.deleteAll()
faceDao.resetId()
recognizeViewModel.refreshFaceList()
withContext(Dispatchers.Main) { block() }
}
}
}
@@ -1,16 +1,13 @@
package com.sw.platecabinet.activity
import android.content.Intent
import android.util.Log
import androidx.activity.viewModels
import com.sw.plate.utils.AppUtil
import com.sw.plate.utils.ToastUtils
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.databinding.ActivityDeviceInitBinding
import com.sw.platecabinet.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.ext.gone
import com.sw.platecabinet.ext.invisible
import com.sw.platecabinet.viewmodel.SettingViewModel
import com.sw.platecabinet.member.databinding.ActivityDeviceInitBinding
import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.mqtt.FaceMqttSubscriber
import com.sw.platecabinet.utils.SpTool
import timber.log.Timber
/**
* 设备初始化界面
@@ -26,45 +23,46 @@ class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
}
override fun initialize() {
// val isSuccess = deviceViewModel.checkEquipmentInfo()
// if (isSuccess) {
// goLoginActivity()
// return
// }
// binding.ivQrCode.setImageBitmap(
// QRCodeUtil.generateQRCode(
// content = GlobalData.deviceId,
// size = 200
// )
// )
// binding.btnInit.setOnClickListener {
// deviceViewModel.getDeviceToken()
// }
// binding.btnInit.gone()
// binding.ivQrCode.invisible()
// binding.root.postDelayed({
// goLoginActivity()
// }, 1000)
showWaitingDialog("加载中……")
viewModel.getDeviceConfig { deviceConfig ->
// 首次启动先全量拉取人脸缓存,后续走增量同步
if (SpTool.getFirstGetFace()) {
netViewModelV2.getUserFaceCache { status, msg ->
SpTool.setFirstGetFace(false)
getDeviceConfig()
}
} else {
getDeviceConfig()
}
}
/**
* 获取设备配置(V2),并初始化虹软 SDK 激活参数
*/
private fun getDeviceConfig() {
netViewModelV2.getDeviceConfig(onSuccess = { deviceConfig ->
runOnUiThread {
if (deviceConfig == null) {
hideWaitingDialog()
ToastUtils.showToast("获取设备配置数据失败")
hideWaitingDialog()
ToastUtils.showToast("获取设备配置失败,请到登录页连点 3 次切换环境")
goLoginActivity()
return@runOnUiThread
}
GlobalData.appId = deviceConfig.arcsoftAppId ?: ""
GlobalData.sdkKey = deviceConfig.arcsoftSdkKey ?: ""
// 测试设备后台下发的激活码不正确,手动硬编码覆盖为正确值(后台修复后可回退为下行)
GlobalData.activeKey = deviceConfig.arcsoftActiveKey ?: ""
binding.root.postDelayed({
hideWaitingDialog()
goLoginActivity()
}, 500)
// GlobalData.activeKey = "085F-118G-Q4V1-THBP"
hideWaitingDialog()
goLoginActivity()
}
}
// goLoginActivity()
}, onFailure = { errMsg ->
runOnUiThread {
hideWaitingDialog()
Timber.e("getDeviceConfig onFailure: $errMsg")
ToastUtils.showToast("服务连接失败,请到登录页连点 3 次切换环境")
goLoginActivity()
}
})
}
override fun registerDataChange() {
@@ -79,6 +77,9 @@ class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
}
private fun goLoginActivity() {
// 首次全量同步(如需)已完成,此时启动人脸 MQTT 实时订阅,
// 避免与首次全量同步的 clearFaceData 产生并发写竞态
FaceMqttSubscriber.start()
val intent = Intent(this, LoginByFaceActivity::class.java)
startActivity(intent)
finish()
@@ -3,8 +3,8 @@ package com.sw.platecabinet.activity
import androidx.lifecycle.lifecycleScope
import com.sw.plate.utils.arcface.FaceApi
import com.sw.plate.utils.arcface.faceserver.FaceServer
import com.sw.platecabinet.databinding.ActivityInitBinding
import com.sw.platecabinet.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.member.databinding.ActivityInitBinding
import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -24,30 +24,56 @@ import com.sw.plate.utils.arcface.PreviewConfig
import com.sw.plate.utils.arcface.camera.CameraListener
import com.sw.plate.utils.arcface.camera.DualCameraHelper
import com.sw.plate.utils.arcface.face.constants.LivenessType
import com.sw.plate.utils.arcface.face.model.CompareResult
import com.sw.plate.utils.arcface.face.model.FacePreviewInfo
import com.sw.plate.utils.arcface.face.model.RecognizeConfiguration
import com.sw.plate.utils.arcface.faceserver.FaceServer
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import com.sw.plate.utils.arcface.viewmodel.RecognizeViewModel
import com.sw.platecabinet.R
import com.sw.platecabinet.databinding.ActivityLoginFaceBinding
import com.sw.platecabinet.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.utils.Debouncer
import com.sw.platecabinet.member.R
import com.sw.platecabinet.member.databinding.ActivityLoginFaceBinding
import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.model.response.EquipmentUserInfo
import com.sw.platecabinet.mqtt.FaceChangedEvent
import com.sw.platecabinet.mqtt.FaceSyncTriggerEvent
import com.sw.platecabinet.network.task.TaskManager
import com.sw.platecabinet.utils.PermissionHelper
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import timber.log.Timber
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.os.Process
import android.view.MotionEvent
import androidx.lifecycle.lifecycleScope
import com.sw.plate.utils.arcface.facedb.FaceDatabase
import com.sw.platecabinet.Environment
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.GlobalKey
import com.sw.platecabinet.dialog.EnvironmentSelectDialog
import com.sw.platecabinet.utils.SpTool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* 人脸识别
*/
class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
ViewTreeObserver.OnGlobalLayoutListener {
private val recognizeViewModel by viewModels<RecognizeViewModel>()
private var countDownTimer: CountDownTimer? = null
// 连点切换环境计数与复位
private var tapCount = 0
private var switchedEnvironment = false
private val tapHandler = Handler(Looper.getMainLooper())
private val resetTapRunnable = Runnable { tapCount = 0 }
private val CAMERA_PERMISSION_REQUEST_CODE = 100
private val REQUIRED_PERMISSIONS: Array<String> = arrayOf(
Manifest.permission.CAMERA,
@@ -73,21 +99,17 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
override fun initialize() {
instance = this
viewModel.activeEngine()
// V2 引擎激活(appId/sdkKey/activeKey 来自 V2 设备配置)
netViewModelV2.activeEngine()
initCountTime()
initArcViewModel()
initArcView()
openRectInfoDraw = true
recognizeViewModel.setDrawRectInfoTextValue(true)
//viewModel.generateToken()
// viewModel.activeEngine()
viewModel.getUserFaceCache()
binding.llToPwd.setOnClickListener {
val intent = Intent(this, LoginByPwdActivity::class.java)
startActivity(intent)
}
//开启人脸增量数据定时任务(V2
startFaceTask()
// TODO: 测试请求
TaskManager.startTask()
@@ -118,6 +140,32 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
Timber.tag("performSync over").e("-time=%s", insertEntity.registerTime)
}
/**
* MQTT 实时同步后的人脸库已落库,防抖重载识别引擎内存(合并短时间内连续变更)。
* scheduleFaceRefresh 定义于 BaseActivity,统一 MQTT/HTTP 增量/定时轮询三路刷新入口。
*/
@Subscribe(threadMode = ThreadMode.MAIN)
fun onFaceChanged(event: FaceChangedEvent) {
Timber.d("onFaceChanged 收到人脸实时变更,2s 后刷新引擎内存")
scheduleFaceRefresh()
}
/**
* MQTT 连接/重连成功,补拉一次 HTTP 增量兜底(设备离线期间漏收的变更)
*/
@Subscribe(threadMode = ThreadMode.MAIN)
fun onFaceSyncTrigger(event: FaceSyncTriggerEvent) {
val timestamp = SpTool.getLastFaceTimestamp()
if (timestamp == 0L) {
Timber.d("onFaceSyncTrigger 水位为 0,跳过增量补拉")
return
}
Timber.d("onFaceSyncTrigger MQTT 已连接,执行 HTTP 增量补拉 timestamp=$timestamp")
netViewModelV2.getFaceIncrementList(pageNo = 1, timestamp = timestamp) {
scheduleFaceRefresh()
}
}
override fun onLeftDoubleClick() {
finish()
}
@@ -171,9 +219,12 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
Timber.i("recognizeNotice observe notice = $notice")
})
recognizeViewModel.recognizeUserId.observe(this, Observer { userId: String? ->
Timber.i("recognizeUserId observe userId = $userId")
viewModel.getUserInfoById(memberId = userId)
recognizeViewModel.recognizeUserId.observe(this, Observer { result: CompareResult? ->
Timber.i(
"recognizeUserId observe userId=${result?.faceEntity?.userName}," +
"similar=${result?.similar},userType=${result?.faceEntity?.userType}"
)
loadFaceRecognizeResult(result)
})
recognizeViewModel.drawRectInfoText.observe(this, Observer { info ->
@@ -187,6 +238,47 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
recognizeViewModel.getCompareResultList().getValue()
}
/**
* 识别阈值,默认值为0.8
*/
private val faceSuccessThreshold by lazy { ConfigUtil.getRecognizeThreshold(this) }
/**
* 识别成功后开柜(查询用户信息)防抖,防止重复触发
*/
private val loginDebouncer = Debouncer(3000)
/**
* 处理识别结果:达到阈值走登录流程,未达阈值仅记录(本版本无采集模式)
*/
private fun loadFaceRecognizeResult(result: CompareResult?) {
val similar = result?.similar ?: return
Timber.i(
"loadFaceRecognizeResult 阈值=$faceSuccessThreshold, similar=$similar"
)
if (similar >= faceSuccessThreshold) {
val userId = result.faceEntity?.userName
recognizeSuccess(userId)
return
}
// 未达阈值:暂不处理(无采集模式),等待下一次识别
}
/**
* 识别成功:防抖后查询用户信息,走开柜门业务流程
*/
private fun recognizeSuccess(userId: String?) {
if (userId.isNullOrBlank()) {
Timber.i("recognizeSuccess 识别成功但 userId 为空,忽略")
return
}
Timber.i("recognizeSuccess userId=$userId")
loginDebouncer.debounce {
viewModel.getUserInfoById(memberId = userId)
}
}
override fun onDestroy() {
Timber.d("onDestroy")
instance = null
@@ -277,7 +369,7 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
val layoutParams = adjustPreviewViewSize(
binding.dualCameraTexturePreviewRgb,
binding.dualCameraTexturePreviewRgb, binding.dualCameraFaceRectView,
previewSizeRgb, displayOrientation, 0.6f
previewSizeRgb, displayOrientation, 0.7f
)
Timber.d("initRgbCamera previewSizeRgb = ${previewSizeRgb.width}-${previewSizeRgb.height}")
Timber.d("initRgbCamera layoutParams = ${layoutParams.width}-${layoutParams.height}")
@@ -392,14 +484,24 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
}
private fun resumeCamera() {
isRecognition = true
if (rgbCameraHelper != null && rgbCameraHelper!!.isStopped) {
rgbCameraHelper!!.start()
val helper = rgbCameraHelper
if (helper != null && helper.isStopped) {
isRecognition = true
helper.start()
} else {
// 相机未停止时,发送黑帧清除 ViewModel 内部识别缓存,防止恢复后使用旧数据
recognizeViewModel.onPreviewFrame(emptyFrame, true)
binding.dualCameraFaceRectView.clearFaceInfo()
binding.root.postDelayed({
isRecognition = true
}, 500)
}
}
override fun onResume() {
super.onResume()
// 清空上次识别结果和 FaceHelper 内部状态,防止短时间内再次识别无法触发
recognizeViewModel.resetFaceState()
resumeCamera()
viewModel.resetUserInfo()
countDownTimer?.let {
@@ -416,10 +518,26 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
private fun pauseCamera() {
isRecognition = false
recognizeViewModel.onPreviewFrame(ByteArray(1382400), true)
recognizeViewModel.onPreviewFrame(emptyFrame, true)
}
/**
* 构造符合分辨率的 NV21 格式全黑帧
* @param width 图像宽度
* @param height 图像高度
* @return 合法的全黑帧字节数组
*/
private fun createBlackNV21Frame(width: Int, height: Int): ByteArray {
val frameSize = width * height
val nv21Data = ByteArray(frameSize * 3 / 2) // NV21 格式长度 = 宽×高×1.5
// Y 分量(亮度)设为 0(全黑),UV 分量设为 128(默认值)
nv21Data.fill(0, 0, frameSize)
nv21Data.fill(128.toByte(), frameSize, nv21Data.size)
return nv21Data
}
private val emptyFrame = createBlackNV21Frame(1280, 720)
override fun handleLoginSuccess(equipmentUserInfo: EquipmentUserInfo, isAdmin: Boolean) {
super.handleLoginSuccess(equipmentUserInfo, isAdmin)
// goInitActivity()
@@ -444,5 +562,77 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
startActivity(intent)
}
/**
* 连续点击 3 次(500ms 内)触发环境切换弹窗
*/
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (ev.action == MotionEvent.ACTION_DOWN) {
tapCount++
tapHandler.removeCallbacks(resetTapRunnable)
tapHandler.postDelayed(resetTapRunnable, 500)
if (tapCount >= 3) {
tapCount = 0
tapHandler.removeCallbacks(resetTapRunnable)
showEnvironmentDialog()
return true
}
}
return super.dispatchTouchEvent(ev)
}
/**
* 弹出环境选择弹窗,弹窗期间暂停 30s 倒计时,避免被自动跳转打断
*/
private fun showEnvironmentDialog() {
countDownTimer?.cancel()
val dialog = EnvironmentSelectDialog(this) { env ->
switchedEnvironment = true
switchEnvironment(env)
}
dialog.setOnDismissListener {
// 未切换(点外部取消)时恢复倒计时;切换后走自动重启,无需恢复
if (!switchedEnvironment) countDownTimer?.start()
}
dialog.show()
}
/**
* 切换环境:同步落盘 + 清空本地人脸库 + 重置增量同步状态 + 自动重启
*/
private fun switchEnvironment(env: Environment) {
// 用 commit 同步写盘,确保自动重启前状态已持久化(apply 是异步的,重启会丢)
getSharedPreferences("default_sp", Context.MODE_PRIVATE).edit()
.putString(GlobalKey.KEY_BASE_URL, env.url)
.putLong(SpTool.LAST_FACE_TIMESTAMP, 0L)
.putBoolean(SpTool.IS_FIRST_GET_FACE, true)
.commit()
GlobalData.appBaseUrl = env.url
lifecycleScope.launch(Dispatchers.IO) {
val faceDao = FaceDatabase.getInstance(this@LoginByFaceActivity).faceDao()
faceDao.deleteAll()
faceDao.resetId()
withContext(Dispatchers.Main) {
ToastUtils.showToast("已切换到${env.name},正在重启")
restartApp()
}
}
}
/**
* 自动重启:用 AlarmManager 拉起启动页后杀进程,实现应用自重启
*/
private fun restartApp() {
val intent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
} ?: return
val pending = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmManager.set(AlarmManager.RTC, System.currentTimeMillis() + 500, pending)
Process.killProcess(Process.myPid())
}
}
@@ -1,62 +0,0 @@
package com.sw.platecabinet.activity
import android.content.Intent
import com.sw.plate.utils.ToastUtils
import com.sw.platecabinet.databinding.ActivityLoginByPwdBinding
import com.sw.platecabinet.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.dialog.BalanceNotEnoughDialog
import com.sw.platecabinet.model.request.LoginParam
import com.sw.platecabinet.model.response.EquipmentUserInfo
import com.sw.platecabinet.utils.KeyboardUtils
/**
* 密码登录
*/
class LoginByPwdActivity : BaseActivity<ActivityLoginByPwdBinding>() {
override fun inflateViewBinding(): ActivityLoginByPwdBinding {
return ActivityLoginByPwdBinding.inflate(layoutInflater)
}
override fun inflateTitleBinding(): ItemTitleTimeBinding {
return binding.includeHeader
}
override fun initialize() {
binding.btnLogin.setOnClickListener {
val phone = binding.etPhone.text.toString()
val pwd = binding.etPwd.text.toString()
if (phone.isEmpty() || pwd.isEmpty()) {
ToastUtils.showToast("手机或校验码不能为空")
return@setOnClickListener
}
val loginParam = LoginParam(
//equipmentId = equipmentId,
phone = phone,
password = pwd
)
viewModel.loginWithPwd(loginParam)
KeyboardUtils.hideKeyboard(this)
}
binding.tvFaceRec.setOnClickListener {
val intent = Intent(this, LoginByFaceActivity::class.java)
startActivity(intent)
}
}
override fun onResume() {
super.onResume()
viewModel.resetUserInfo()
}
override fun handleLoginSuccess(equipmentUserInfo: EquipmentUserInfo, isAdmin: Boolean) {
super.handleLoginSuccess(equipmentUserInfo, isAdmin)
// val cardBalance = equipmentUserInfo.cardBalance?:0.toDouble()
// val balanceIsNotEnough = cardBalance <= 0.toDouble()
if (equipmentUserInfo.isIntercept) {
return
}
LoginByFaceActivity.goInitActivity()
finish()
}
}
@@ -2,9 +2,9 @@ package com.sw.platecabinet.activity
import android.content.Context
import android.content.Intent
import com.sw.platecabinet.R
import com.sw.platecabinet.databinding.ActivityMainBinding
import com.sw.platecabinet.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.member.R
import com.sw.platecabinet.member.databinding.ActivityMainBinding
import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.fragment.BindPlateFragment
import com.sw.platecabinet.fragment.PlateCabinetFullFragment
import com.sw.platecabinet.fragment.PlateOpenFragment
@@ -73,7 +73,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>() {
}
override fun onRightDoubleClick() {
// 管理员列表页右上角时间双击 → 打开运维面板
if (pageType == PageType.SETTING_LIST) {
OpsActivity.start(this)
}
}
override fun handleScanKeyInfo(scanInfo: String) {
@@ -0,0 +1,389 @@
package com.sw.platecabinet.activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.text.InputType
import android.widget.EditText
import android.widget.TextView
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import com.sw.plate.utils.comn.SerialApi
import com.sw.platecabinet.dialog.EnvironmentSelectDialog
import com.sw.platecabinet.member.databinding.ActivityOpsBinding
import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.mqtt.MqttManager
import com.sw.platecabinet.mqtt.MqttState
import com.sw.platecabinet.utils.CrashHandler
import com.sw.platecabinet.utils.DeviceInfoProvider
import com.sw.platecabinet.utils.DiagnosticExporter
import com.sw.platecabinet.utils.EnvironmentSwitcher
import com.sw.platecabinet.utils.LogFileManager
import com.sw.platecabinet.utils.NetStatusProvider
import com.sw.platecabinet.utils.SpTool
import com.sw.platecabinet.viewmodel.OpsViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* 运维面板:展示网络 / MQTT / 人脸数据 / 最近更新 / 日志,并提供少量二次确认干预。
* 入口:管理员列表页(MainActivity SETTING_LIST)右上角时间双击。
*/
class OpsActivity : BaseActivity<ActivityOpsBinding>() {
private val opsViewModel by viewModels<OpsViewModel>()
override fun inflateViewBinding(): ActivityOpsBinding {
return ActivityOpsBinding.inflate(layoutInflater)
}
override fun inflateTitleBinding(): ItemTitleTimeBinding? {
return binding.includeHeader
}
override fun initialize() {
checkPin()
}
/** 进入面板前校验运维密码(默认值见 SpTool,不在 UI 明文提示) */
private fun checkPin() {
val input = EditText(this).apply {
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
hint = "请输入密码"
}
AlertDialog.Builder(this)
.setTitle("请输入运维密码")
.setView(input)
.setCancelable(false)
.setNegativeButton("退出") { _, _ -> finish() }
.setPositiveButton("确定") { _, _ ->
if (input.text.toString() == SpTool.getOpsPin()) {
proceedInit()
} else {
ToastUtils.showToast("密码错误")
finish()
}
}
.show()
}
private fun proceedInit() {
renderStaticInfo()
opsViewModel.refreshFaceStats()
opsViewModel.refreshLogFiles(this)
bindActions()
startAutoRefresh()
}
/** 30s 定时刷新静态信息与人脸/日志列表 */
private fun startAutoRefresh() {
lifecycleScope.launch {
while (isActive) {
delay(30_000)
renderStaticInfo()
renderSerialInfo()
opsViewModel.refreshFaceStats()
opsViewModel.refreshLogFiles(this@OpsActivity)
}
}
}
override fun registerDataChange() {
super.registerDataChange()
// MQTT 状态与时间线变化 → 刷新 MQTT 卡
lifecycleScope.launch { opsViewModel.mqttState.collect { renderMqttCard() } }
lifecycleScope.launch { opsViewModel.mqttLastConnectedAt.collect { renderMqttCard() } }
lifecycleScope.launch { opsViewModel.mqttLastLostAt.collect { renderMqttCard() } }
lifecycleScope.launch { opsViewModel.mqttLastError.collect { renderMqttCard() } }
lifecycleScope.launch { opsViewModel.mqttLastMessageAt.collect { renderMqttCard() } }
lifecycleScope.launch { opsViewModel.mqttConnectCount.collect { renderMqttCard() } }
lifecycleScope.launch { opsViewModel.mqttDisconnectCount.collect { renderMqttCard() } }
// 人脸统计
lifecycleScope.launch { opsViewModel.faceCount.collect { renderFaceCard() } }
lifecycleScope.launch { opsViewModel.memberCount.collect { renderFaceCard() } }
lifecycleScope.launch { opsViewModel.nonMemberCount.collect { renderFaceCard() } }
lifecycleScope.launch { opsViewModel.maxUpdateTs.collect { renderFaceCard() } }
lifecycleScope.launch { opsViewModel.recentFaces.collect { renderRecentFaces() } }
lifecycleScope.launch { opsViewModel.searchResult.collect { renderSearchResult(it) } }
// 服务端自检与日志列表
lifecycleScope.launch { opsViewModel.serverCheck.collect { binding.tvServerCheck.text = it } }
lifecycleScope.launch { opsViewModel.logFiles.collect { renderLogFiles() } }
}
/** 左上角日期双击 → 返回 */
override fun onLeftDoubleClick() {
finish()
}
private fun renderStaticInfo() {
binding.tvNetSummary.text =
"${NetStatusProvider.connectivitySummary(this)} | 本机IP: ${NetStatusProvider.localIpv4()}"
binding.tvNetBaseUrl.text = "环境: ${opsViewModel.envName} | ${opsViewModel.appBaseUrl}"
binding.tvDeviceInfo.text =
"设备ID: ${opsViewModel.deviceId}\n设备编号: ${opsViewModel.equipmentCode} | 版本: ${opsViewModel.appVersion}"
binding.tvDeviceHealth.text =
"运行时长: ${DeviceInfoProvider.uptime()}\n内存: ${DeviceInfoProvider.memorySummary(this)}\n存储: ${DeviceInfoProvider.internalStorage(this)}"
binding.tvCameraInfo.text = DeviceInfoProvider.cameraSummary(this)
renderSerialInfo()
}
private fun renderSerialInfo() {
val state = if (SerialApi.isOpened()) "已打开" else "未打开"
binding.tvSerialInfo.text = "串口: ${SerialApi.getPath()} @ ${SerialApi.getBaudRate()} | 状态: $state"
}
private fun renderMqttCard() {
binding.tvMqttState.text = "状态: ${opsViewModel.mqttState.value.name}"
val broker = opsViewModel.mqttBrokerUrl ?: "未配置"
val clientId = opsViewModel.mqttClientId ?: "未配置"
binding.tvMqttBroker.text = "Broker: $broker\nclientId: $clientId"
val subs = MqttManager.getSubscriptions().entries
.joinToString("; ") { "${it.key}(qos=${it.value})" }
.ifBlank {
if (opsViewModel.mqttState.value == MqttState.Connected) "无(已连接但无订阅)"
else "无(未连接/尚未订阅)"
}
binding.tvMqttSubs.text = "订阅: $subs"
val connected = opsViewModel.formatTs(opsViewModel.mqttLastConnectedAt.value)
val lost = opsViewModel.formatTs(opsViewModel.mqttLastLostAt.value)
val err = opsViewModel.mqttLastError.value ?: ""
val lastMsg = ago(opsViewModel.mqttLastMessageAt.value)
binding.tvMqttTimes.text =
"连接: $connected | 丢失: $lost\n错误: $err\n最近消息: $lastMsg | 连接${opsViewModel.mqttConnectCount.value}次 / 断开${opsViewModel.mqttDisconnectCount.value}"
}
private fun ago(ts: Long?): String {
if (ts == null) return ""
val diff = System.currentTimeMillis() - ts
return when {
diff < 60_000L -> "${diff / 1000}秒前"
diff < 3_600_000L -> "${diff / 60_000}分钟前"
else -> "${diff / 3_600_000}小时前"
}
}
private fun renderFaceCard() {
binding.tvFaceCount.text =
"人脸总数: ${opsViewModel.faceCount.value} | 会员: ${opsViewModel.memberCount.value} | 非会员: ${opsViewModel.nonMemberCount.value}"
val maxTs = opsViewModel.formatTs(opsViewModel.maxUpdateTs.value)
val watermark = opsViewModel.formatTs(opsViewModel.lastFaceTimestamp)
binding.tvFaceWatermark.text = "同步水位: $watermark | 库内最大更新: $maxTs"
}
private fun renderRecentFaces() {
val list = opsViewModel.recentFaces.value
binding.llRecentList.removeAllViews()
binding.tvRecentEmpty.isVisible = list.isEmpty()
list.take(20).forEach { face ->
binding.llRecentList.addView(
TextView(this).apply {
text = buildFaceLine(face)
setTextColor(Color.parseColor("#E6E6E6"))
textSize = 13f
}
)
}
}
private fun buildFaceLine(face: FaceEntity): String {
val updateTs = opsViewModel.formatTs(face.faceUpdateTimestamp)
val insertTs = opsViewModel.formatTs(face.registerTime)
val member = if (face.isMember) "会员" else "非会员"
return "ufid=${face.userFaceId ?: "—"} uid=${face.userId ?: "—"} $member\n 更新: $updateTs | 落库: $insertTs"
}
private fun renderSearchResult(list: List<FaceEntity>) {
binding.tvSearchResult.isVisible = true
binding.tvSearchResult.text = if (list.isEmpty()) "无匹配结果"
else "匹配 ${list.size} 条:\n" + list.take(20).joinToString("\n") { buildFaceLine(it) }
}
private fun renderLogFiles() {
val files = opsViewModel.logFiles.value
val crashFiles = CrashHandler.getCrashReportFiles(this)
binding.tvLogFiles.text = "运行日志: ${files.size} 个文件" +
if (files.isEmpty()) "" else "\n最新: ${files.firstOrNull()?.name}"
val lastCrash = crashFiles.maxOfOrNull { it.lastModified() }
binding.tvCrashInfo.text = "崩溃日志: ${crashFiles.size} 个文件" +
if (lastCrash == null) "" else "\n最近崩溃: ${opsViewModel.formatTs(lastCrash)}"
}
private fun bindActions() {
binding.btnCheckServer.setOnClickListener { opsViewModel.checkServer() }
binding.btnRefreshFace.setOnClickListener { opsViewModel.refreshFaceStats() }
binding.btnSearch.setOnClickListener {
val keyword = binding.etSearch.text.toString().trim()
if (keyword.isEmpty()) {
ToastUtils.showToast("请输入 userId")
return@setOnClickListener
}
opsViewModel.searchFace(keyword)
}
binding.btnInitSerial.setOnClickListener {
SerialApi.init()
renderSerialInfo()
ToastUtils.showToast(if (SerialApi.isOpened()) "串口打开成功" else "串口打开失败")
}
binding.btnReconnectMqtt.setOnClickListener {
confirm("MQTT 手动重连", "断开并重新连接 MQTT,确定?") {
opsViewModel.reconnectMqtt()
ToastUtils.showToast("已触发重连")
}
}
binding.btnFullSync.setOnClickListener {
confirm("获取全量人脸", "将清空本地人脸库并重新全量拉取(耗时较长),确定?") {
showWaitingDialog("全量同步中…")
netViewModelV2.getUserFaceCache { success, msg ->
runOnUiThread {
hideWaitingDialog()
if (success) {
scheduleFaceRefresh()
opsViewModel.refreshFaceStats()
ToastUtils.showToast("全量同步完成")
} else {
ToastUtils.showToast("全量同步失败: $msg")
}
}
}
}
}
binding.btnTriggerSync.setOnClickListener {
val ts = SpTool.getLastFaceTimestamp()
if (ts <= 0L) {
ToastUtils.showToast("同步水位为 0,请先完成首次全量同步")
return@setOnClickListener
}
confirm("手动增量补拉", "以水位 $ts 触发一次 HTTP 增量同步,确定?") {
netViewModelV2.getFaceIncrementList(timestamp = ts) { scheduleFaceRefresh() }
ToastUtils.showToast("已触发增量补拉")
}
}
binding.btnClearFace.setOnClickListener {
confirm("清空本地人脸库", "将删除全部本地人脸特征并刷新识别引擎,确定?") {
clearAllFace { ToastUtils.showToast("已清空本地人脸库") }
}
}
binding.btnReadLog.setOnClickListener { readLatestLog() }
binding.btnReadCrash.setOnClickListener { readLatestCrash() }
binding.btnExport.setOnClickListener {
lifecycleScope.launch(Dispatchers.IO) {
val result = DiagnosticExporter.export(this@OpsActivity)
withContext(Dispatchers.Main) {
AlertDialog.Builder(this@OpsActivity)
.setTitle(if (result.success) "导出成功" else "导出失败")
.setMessage(result.message)
.setPositiveButton("确定", null)
.show()
}
}
}
binding.btnClearLogs.setOnClickListener {
confirm("清空日志", "删除全部运行日志与崩溃日志,确定?") {
LogFileManager.clearLogs(this)
CrashHandler.clearCrashReports(this)
opsViewModel.refreshLogFiles(this)
ToastUtils.showToast("已清空日志")
}
}
binding.btnSwitchEnv.setOnClickListener {
val dialog = EnvironmentSelectDialog(this) { env ->
EnvironmentSwitcher.switch(this, env) {
ToastUtils.showToast("已切换到${env.name},正在重启")
}
}
dialog.show()
}
binding.btnChangePin.setOnClickListener { changePin() }
}
private fun changePin() {
val input = EditText(this).apply {
inputType = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_VARIATION_PASSWORD
hint = "4 位以上数字"
}
AlertDialog.Builder(this)
.setTitle("设置新运维密码")
.setView(input)
.setNegativeButton("取消", null)
.setPositiveButton("确定") { _, _ ->
val pin = input.text.toString().trim()
if (pin.length < 4) {
ToastUtils.showToast("密码至少 4 位")
} else {
SpTool.setOpsPin(pin)
ToastUtils.showToast("密码已修改")
}
}
.show()
}
private fun readLatestLog() {
val file = opsViewModel.logFiles.value.firstOrNull()
if (file == null) {
ToastUtils.showToast("暂无日志文件")
return
}
lifecycleScope.launch(Dispatchers.IO) {
val content = LogFileManager.readTail(file, 200)
withContext(Dispatchers.Main) {
AlertDialog.Builder(this@OpsActivity)
.setTitle("最新日志: ${file.name}")
.setMessage(content)
.setPositiveButton("关闭", null)
.show()
}
}
}
private fun readLatestCrash() {
val file = CrashHandler.getCrashReportFiles(this).maxByOrNull { it.lastModified() }
if (file == null) {
ToastUtils.showToast("暂无崩溃日志")
return
}
lifecycleScope.launch(Dispatchers.IO) {
val content = LogFileManager.readTail(file, 300)
withContext(Dispatchers.Main) {
AlertDialog.Builder(this@OpsActivity)
.setTitle("最新崩溃日志: ${file.name}")
.setMessage(content)
.setPositiveButton("关闭", null)
.show()
}
}
}
private fun confirm(title: String, message: String, onOk: () -> Unit) {
AlertDialog.Builder(this)
.setTitle(title)
.setMessage(message)
.setNegativeButton("取消", null)
.setPositiveButton("确定") { _, _ -> onOk() }
.show()
}
companion object {
fun start(context: Context) {
context.startActivity(Intent(context, OpsActivity::class.java))
}
}
}
@@ -10,7 +10,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.sw.platecabinet.R
import com.sw.platecabinet.member.R
import kotlin.math.ceil
class TestActivity : AppCompatActivity() {
@@ -6,8 +6,8 @@ import android.view.ViewGroup
import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.WindowCompat
import com.sw.platecabinet.GlobalKey
import com.sw.platecabinet.databinding.ActivityUnbindDialogBinding
import com.sw.platecabinet.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.member.databinding.ActivityUnbindDialogBinding
import com.sw.platecabinet.member.databinding.ItemTitleTimeBinding
import com.sw.platecabinet.ext.dp
import com.sw.platecabinet.model.response.EquipmentUserInfo
@@ -6,7 +6,7 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.sw.platecabinet.R
import com.sw.platecabinet.member.R
/**
* 通用分页适配器(支持ViewBinding
@@ -5,7 +5,7 @@ import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.Window
import androidx.fragment.app.FragmentActivity
import com.sw.platecabinet.databinding.DialogBalanceNotEnoughBinding
import com.sw.platecabinet.member.databinding.DialogBalanceNotEnoughBinding
import com.sw.platecabinet.ext.dp
open class BalanceNotEnoughDialog(
@@ -0,0 +1,97 @@
package com.sw.platecabinet.dialog
import android.app.Dialog
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.text.SpannableString
import android.text.Spanned
import android.text.style.AbsoluteSizeSpan
import android.text.style.ForegroundColorSpan
import android.view.Gravity
import android.view.View
import android.view.Window
import android.widget.LinearLayout
import android.widget.RadioButton
import androidx.fragment.app.FragmentActivity
import com.sw.platecabinet.ENVIRONMENTS
import com.sw.platecabinet.Environment
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.ext.dp
import com.sw.platecabinet.member.databinding.DialogEnvironmentSelectBinding
/**
* 业务环境选择弹窗
*/
class EnvironmentSelectDialog(
private val activity: FragmentActivity,
private val onConfirm: (Environment) -> Unit
) : Dialog(activity) {
private lateinit var binding: DialogEnvironmentSelectBinding
private var selected: Environment? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestWindowFeature(Window.FEATURE_NO_TITLE)
binding = DialogEnvironmentSelectBinding.inflate(layoutInflater)
setContentView(binding.root)
window?.run {
attributes = attributes.apply { width = 480.dp }
setBackgroundDrawable(ColorDrawable())
setCancelable(true)
}
renderEnvironments()
addListener()
}
/**
* 渲染环境选项,高亮当前环境
*/
private fun renderEnvironments() {
val current = GlobalData.appBaseUrl
ENVIRONMENTS.forEach { env ->
val rb = RadioButton(activity).apply {
id = View.generateViewId()
text = buildEnvironmentLabel(env.name, env.url)
gravity = Gravity.CENTER_VERTICAL
setTextColor(Color.WHITE)
buttonTintList = ColorStateList.valueOf(Color.WHITE)
isChecked = env.url == current
setOnClickListener { selected = env }
}
binding.rgEnvironments.addView(
rb,
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { topMargin = 8.dp }
)
}
selected = ENVIRONMENTS.firstOrNull { it.url == current }
}
/**
* 构造环境选项文本:名称(白色 18sp)+ 换行 + 地址(灰色 13sp)
*/
private fun buildEnvironmentLabel(name: String, url: String): SpannableString {
val label = "$name\n$url"
val spannable = SpannableString(label)
val urlStart = name.length + 1 // 跳过换行符
spannable.setSpan(AbsoluteSizeSpan(18, true), 0, name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
spannable.setSpan(AbsoluteSizeSpan(13, true), urlStart, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
spannable.setSpan(ForegroundColorSpan(Color.GRAY), urlStart, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
return spannable
}
/**
* 确认按钮:回调所选环境并关闭弹窗
*/
private fun addListener() {
binding.tvConfirm.setOnClickListener {
selected?.let(onConfirm)
dismiss()
}
}
}
@@ -5,7 +5,7 @@ import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.Window
import androidx.fragment.app.FragmentActivity
import com.sw.platecabinet.databinding.DialogUserBindRemindBinding
import com.sw.platecabinet.member.databinding.DialogUserBindRemindBinding
import com.sw.platecabinet.ext.dp
open class UserBindRemindDialog(
@@ -11,7 +11,7 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.viewbinding.ViewBinding
import com.sw.platecabinet.R
import com.sw.platecabinet.member.R
import com.sw.platecabinet.view.CustomLoadingDialog
import com.sw.platecabinet.viewmodel.SettingViewModel
import kotlinx.coroutines.launch
@@ -14,14 +14,14 @@ import androidx.recyclerview.widget.RecyclerView
import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.comn.SerialApi
import com.sw.plate.utils.comn.SerialPortManager
import com.sw.platecabinet.R
import com.sw.platecabinet.member.R
import com.sw.platecabinet.activity.MainActivity
import com.sw.platecabinet.activity.PageType
import com.sw.platecabinet.adapter.GenericItemAdapter
import com.sw.platecabinet.adapter.GridSpacingItemDecoration
import com.sw.platecabinet.adapter.dpToPx
import com.sw.platecabinet.databinding.FragmentBindPlateBinding
import com.sw.platecabinet.databinding.ItemSearchUserInfoBinding
import com.sw.platecabinet.member.databinding.FragmentBindPlateBinding
import com.sw.platecabinet.member.databinding.ItemSearchUserInfoBinding
import com.sw.platecabinet.dialog.UserBindRemindDialog
import com.sw.platecabinet.ext.maskName
import com.sw.platecabinet.ext.maskPhone
@@ -262,7 +262,7 @@ class BindPlateFragment : BaseFragment<FragmentBindPlateBinding>(
//TODO 调用接口查询用户是否已绑盘
this@BindPlateFragment.equipmentBoxCode = null
(activity as? MainActivity)?.showWaitingDialog("查询中,请稍后……")
userViewModel.getUserInfoById(memberId = item.faceId) {
userViewModel.getUserInfoById(memberId = item.faceId, silent = true) {
(activity as? MainActivity)?.hideWaitingDialog()
this@BindPlateFragment.equipmentBoxCode = it?.equipmentBoxCode
}
@@ -306,7 +306,10 @@ class BindPlateFragment : BaseFragment<FragmentBindPlateBinding>(
}
override fun onFail(e: Exception?) {
ToastUtils.showToast("绑定失败")
// 走到这里说明 plateBinding 接口已成功(code=00000),
// 只是开柜动作失败,不能误报「绑定失败」
ToastUtils.showToast("绑定成功,开柜失败")
activity?.finish()
}
})
// }
@@ -2,7 +2,7 @@ package com.sw.platecabinet.fragment
import android.os.CountDownTimer
import com.sw.platecabinet.Constants
import com.sw.platecabinet.databinding.FragmentPlateCabinetFullBinding
import com.sw.platecabinet.member.databinding.FragmentPlateCabinetFullBinding
import timber.log.Timber
/**
@@ -4,9 +4,9 @@ import android.content.Intent
import android.os.Bundle
import android.os.CountDownTimer
import com.sw.platecabinet.Constants
import com.sw.platecabinet.R
import com.sw.platecabinet.member.R
import com.sw.platecabinet.activity.InitActivity
import com.sw.platecabinet.databinding.FragmentPlateOpenBinding
import com.sw.platecabinet.member.databinding.FragmentPlateOpenBinding
import com.sw.platecabinet.model.response.EquipmentUserInfo
/**
@@ -11,15 +11,15 @@ import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.comn.SerialApi
import com.sw.plate.utils.comn.SerialPortManager
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.R
import com.sw.platecabinet.member.R
import com.sw.platecabinet.activity.MainActivity
import com.sw.platecabinet.activity.PageType
import com.sw.platecabinet.adapter.GenericItemAdapter
import com.sw.platecabinet.adapter.GenericPageAdapter
import com.sw.platecabinet.adapter.GridSpacingItemDecoration
import com.sw.platecabinet.adapter.dpToPx
import com.sw.platecabinet.databinding.FragmentSettingListBinding
import com.sw.platecabinet.databinding.ItemBindViewBinding
import com.sw.platecabinet.member.databinding.FragmentSettingListBinding
import com.sw.platecabinet.member.databinding.ItemBindViewBinding
import com.sw.platecabinet.ext.formatNumber
import com.sw.platecabinet.ext.maskName
import com.sw.platecabinet.model.response.EquipmentUserInfo
@@ -9,7 +9,7 @@ import com.sw.inbound.utils.DateTimeUtils
import com.sw.plate.utils.ToastUtils
import com.sw.platecabinet.GlobalKey
import com.sw.platecabinet.activity.UnBindDialogActivity
import com.sw.platecabinet.databinding.FragmentUnbindPlateBinding
import com.sw.platecabinet.member.databinding.FragmentUnbindPlateBinding
import com.sw.platecabinet.ext.maskName
import com.sw.platecabinet.ext.maskPhone
import com.sw.platecabinet.model.ErrorInfo
@@ -21,6 +21,28 @@ data class DeviceConfig(
var arcsoftActiveKey: String? = null
)
/**
* V2 设备配置(人脸 5.0 体系)
*/
data class DeviceConfigV2(
/** 业务服务器 BASE URL */
val appPackageUrl: String? = null,
/** 食堂/餐厅名称 */
val canteenName: String? = null,
/** 食堂/餐厅 ID(后续所有业务 API 的 restId */
val canteenId: String? = null,
/** 虹软人脸 SDK App ID */
val arcsoftAppId: String? = null,
/** 虹软人脸 SDK Key */
val arcsoftSdkKey: String? = null,
/** 虹软人脸 SDK Active Key */
val arcsoftActiveKey: String? = null,
/** MQTT 客户端服务器 IP */
val clientServerIp: String? = null,
/** 智慧食堂服务器 IP(MQTT 端口) */
val zhstServerIp: String? = null
)
data class CodeMsg(
val code: String? = "",
val msg: String? = ""
@@ -0,0 +1,6 @@
package com.sw.platecabinet.model.request
data class FeatureBody(
val url:String,
val featureChar:String
)
@@ -0,0 +1,13 @@
package com.sw.platecabinet.model.request
/**
* 通用 ID 请求体
*
* 用于按用户 id(餐盘号)查询或上报类的接口,例如:
* - P-09a 取餐盘(/neglect/pickup/plate-pickup
*
* @property id 用户 id(餐盘号 = userId
*/
data class IdDTO(
val id: Long
)
@@ -1,30 +0,0 @@
package com.sw.platecabinet.model.request
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
/**
* 登录参数
*/
@Parcelize
data class LoginParam(
/**
* 设备Id
*/
val equipmentId: Int? = null,
/**
* 会员信息
*/
// val memberId: String? = null,
val faceId: String? = null,
/**
* 密码
*/
val password: String? = null,
/**
* 手机号
*/
val phone: String? = null
) : Parcelable
@@ -26,9 +26,9 @@ data class SearchParam(
// @SerializedName("memberFrom")
// val memberFrom: Int? = 0,
// @SerializedName("pageNum")
var pageNum: Int? = 0,
var pageNum: Int? = 1,
// @SerializedName("pageSize")
var pageSize: Int? = 0,
var pageSize: Int? = 10,
// @SerializedName("param")
// val `param`: String? = "",
var name:String?=null,
@@ -5,6 +5,7 @@ data class ApiResponse<T>(
// val success: Boolean? = false,
val msg: String? = "",
val data: T? = null,
val total: Int = 0,
// val result: T? = null,
) {
// fun isSuccess(): Boolean = code == 200
@@ -12,9 +12,9 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class EquipmentUserInfo(
/**
* 主键
* 主键(绑定记录id,后端序列化为字符串)
*/
val id: Long? = 0,
val id: String? = "",
/**
*
* 设备Id
@@ -2,7 +2,6 @@ package com.sw.platecabinet.model.response
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize
/**
@@ -10,21 +9,20 @@ import kotlinx.parcelize.Parcelize
*/
@Parcelize
data class UserFaceModel(
// @SerializedName("faceFeature")
// val faceFeature: String? = "",
// @SerializedName("faceFeatureString")
// val faceFeatureString: String? = "",
// @SerializedName("faceType")
// val faceType: String? = "",
// @SerializedName("userFaceId")
// val userFaceId: String? = "",
// @SerializedName("userId")
// val userId: String? = "",
// @SerializedName("updateTimeStamp")
// val updateTimeStamp: Long = 0,
val userId: String? = "",
val faceFeatureStr: String? = "",
val faceUpdateTimestamp:Long?=null,
val faceDeleted: Boolean?=false
val faceUpdateTimestamp: Long? = null,
/**
* 人脸删除标识
*/
val faceDeleted: Boolean? = false,
/**
* 会员编号
*/
val cardNo: String? = null,
/**
* 是否会员
*/
val member: Boolean? = null
) : Parcelable
@@ -0,0 +1,44 @@
package com.sw.platecabinet.model.response
import android.os.Parcelable
import com.google.gson.annotations.SerializedName
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import kotlinx.parcelize.Parcelize
/**
* 用户人脸信息
*/
@Parcelize
data class UserFaceModelV2(
val userFaceId: String? = null,
val userId: String? = null,
val faceFeature: String? = null,
val faceFeatureStr: String? = null,
val faceFeatureString: String? = null,
val faceUpdateTimestamp: Long? = null,
val cardNo: String? = null,
val member: Boolean? = false,
val faceDeleted: Boolean? = false,
val personType: String? = null
) : Parcelable {
fun resolveFeatureStr(): String? {
return faceFeature?.takeIf { it.isNotEmpty() }
?: faceFeatureStr?.takeIf { it.isNotEmpty() }
?: faceFeatureString?.takeIf { it.isNotEmpty() }
}
fun toFaceEntity(): FaceEntity {
val featureBase64 = resolveFeatureStr()
return FaceEntity(
userId,
android.util.Base64.decode(featureBase64, android.util.Base64.DEFAULT),
personType,
cardNo,
userId,
userFaceId,
member ?: false,
faceUpdateTimestamp ?: 0L
)
}
}
@@ -0,0 +1,7 @@
package com.sw.platecabinet.mqtt
/** 人脸库已由 MQTT 实时更新(DB 已落库),通知界面层刷新识别引擎内存 */
class FaceChangedEvent
/** MQTT 连接/重连成功,请求执行一次 HTTP 增量补拉兜底 */
class FaceSyncTriggerEvent
@@ -0,0 +1,295 @@
package com.sw.platecabinet.mqtt
import com.google.gson.JsonParser
import com.sw.plate.utils.Base64
import com.sw.plate.utils.arcface.FaceApi
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.model.response.UserFaceModelV2
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import org.greenrobot.eventbus.EventBus
import timber.log.Timber
/**
* 人脸 MQTT 实时同步订阅器(进程级单例,替代 Hilt 注入)
*
* 订阅服务端人脸变更广播主题(随环境区分):用户在小程序/管理端采集或更换人脸后,
* 服务端实时推送特征码变更,设备端立即更新本地人脸库,无需等待 5 分钟定时增量轮询。
*
* 与增量接口的联动:MQTT 提供秒级实时推送;设备侧另有 5 分钟定时轮询 +
* 连接/重连成功时补拉一次 HTTP 增量,兜底推送失败与设备离线
* (关机/断网/broker 会话过期)漏收的变更。增量水位只由 HTTP 响应推进,
* 本模块不改动水位。
*
* 幂等与乱序按 userFaceId + 时间戳守卫处理(见 [applyRealtimeUpdates])。
*/
object FaceMqttSubscriber {
private const val TAG = "FaceMqttSubscriber"
/** MQTT 账号(dev 域名与测试 IP 共用) */
private const val MQTT_USER = "platform"
private const val MQTT_PASSWORD = "ZrKhZhng6t2tlpid"
/** MQTT 账号(UAT 独立) */
private const val MQTT_USER_UAT = "platform-uat"
private const val MQTT_PASSWORD_UAT = "p2AXu3lsUllbXJEY_A1!"
/**
* 根据业务服务器 BASE_URL 解析对应的 MQTT 环境配置(host、port、协议、订阅主题)。
* 与当前项目的三个预设环境对齐:本地 / 测试 / 生产(UAT)。
* 未匹配的环境返回 null 表示不接入实时同步。
*/
private fun resolveMqttEnv(baseUrl: String): MqttEnv? = when {
baseUrl.startsWith("https://platform-api.uat.shuziweidao.com") ->
MqttEnv("mqtt.uat.shuziweidao.com", 443, "wss", "yx/device/face/update-local",
MQTT_USER_UAT, MQTT_PASSWORD_UAT)
baseUrl.startsWith("https://dev.yixiong-tech.com") ->
MqttEnv("dev.yixiong-tech.com", 8089, "wss", "yx/device/face/update-test",
MQTT_USER, MQTT_PASSWORD)
baseUrl.startsWith("http://192.168.10.101") ->
MqttEnv("192.168.10.101", 1884, "tcp", "yx/device/face/update-dev",
MQTT_USER, MQTT_PASSWORD)
else -> null
}
/** MQTT 环境配置:host、port、协议、订阅主题、账号 */
private data class MqttEnv(
val host: String,
val port: Int,
val scheme: String,
val topic: String,
val userName: String,
val password: String
)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val faceApi = FaceApi()
private var started = false
private var collectJob: Job? = null
private var stateJob: Job? = null
/** 当前环境的人脸变更订阅主题(随 start() 按 baseUrl 环境解析) */
private var faceUpdateTopic: String = ""
/**
* 启动订阅(幂等,重复调用不会建立多条连接)
*
* clientId 使用 `platecabinet-{设备UDID}`,与 X-DEVICE-CODE 同源,保证一台设备一条连接。
* 应在应用启动、设备初始化完成后调用。
*/
fun start() {
if (started) return
started = true
val baseUrl = GlobalData.appBaseUrl
val env = resolveMqttEnv(baseUrl) ?: run {
started = false
Timber.w("$TAG 当前环境(baseUrl=$baseUrl)无 MQTT 配置,跳过实时同步,人脸变更仅靠定时增量轮询兜底")
return
}
faceUpdateTopic = env.topic
val udid = GlobalData.deviceId.ifBlank { "unknown" }
Timber.i(
"$TAG 启动人脸 MQTT 订阅, broker=${env.scheme}://${env.host}:${env.port}, " +
"topic=${env.topic}, clientId=platecabinet-$udid"
)
MqttManager.configure(
MqttConfig(
host = env.host,
port = env.port,
scheme = env.scheme,
clientId = "platecabinet-$udid",
userName = env.userName,
password = env.password,
cleanSession = false
)
)
MqttManager.connect()
// 消费广播消息:解析 → 落库 → 通知界面刷新引擎内存
collectJob = scope.launch {
MqttManager.messages.collect { message ->
if (message is MqttMessage.Received) {
if (message.topic == faceUpdateTopic) {
handleFaceUpdate(message.payload)
} else {
// 联调排查:后台发送的 topic 与预期不一致时在此可见
Timber.w("$TAG 收到非人脸主题消息,忽略: topic=${message.topic} size=${message.payload.size}")
}
}
}
}
// 连接状态联动:连接成功 → 恢复订阅 + 触发一次 HTTP 增量补拉
stateJob = scope.launch {
MqttManager.state.collect { state ->
if (state == MqttState.Connected) {
MqttManager.subscribe(faceUpdateTopic, 1)
// 设备离线期间(关机/断网/会话过期)的变更 MQTT 无法补收,
// 通知界面层用水位时间戳补拉一次 HTTP 增量兜底
EventBus.getDefault().post(FaceSyncTriggerEvent())
}
}
}
}
fun stop() {
if (!started) return
started = false
collectJob?.cancel()
stateJob?.cancel()
collectJob = null
stateJob = null
MqttManager.disconnect()
}
/** 解析广播 JSON 数组并落库(faceUpdateTimestamp 兼容 long/String 两种格式) */
private suspend fun handleFaceUpdate(payload: ByteArray) {
// 联调排查:记录原始报文预览(特征码很长,只打前 120 字符)
val raw = String(payload, Charsets.UTF_8)
Timber.i("$TAG 收到人脸广播: size=${payload.size} 预览=${raw.take(120)}")
val items = try {
parsePayload(raw)
} catch (e: Exception) {
Timber.e(e, "$TAG 广播消息解析失败: ${raw.take(300)}")
return
}
// 联调排查:逐条记录关键字段(最多 5 条,特征码只打长度)
items.take(5).forEach {
Timber.i(
"$TAG 解析条目: userFaceId=${it.userFaceId} userId=${it.userId} " +
"deleted=${it.faceDeleted} ts=${it.faceUpdateTimestamp} " +
"featureLen=${it.resolveFeatureStr()?.length ?: 0}"
)
}
Timber.i("$TAG 收到人脸广播 ${items.size} 条,开始落库")
applyRealtimeUpdates(items)
Timber.i("$TAG 人脸广播处理完成 ${items.size}")
}
/** 解析 JSON 数组为 [UserFaceModelV2](时间戳兼容 long 数字与字符串两种格式) */
private fun parsePayload(raw: String): List<UserFaceModelV2> {
val array = JsonParser.parseString(raw).asJsonArray
return array.mapNotNull { element ->
val obj = element.asJsonObject
val tsElement = obj.get("faceUpdateTimestamp")
val timestamp = when {
tsElement == null || tsElement.isJsonNull -> null
tsElement.isJsonPrimitive -> tsElement.asString.toLongOrNull()
else -> null
}
UserFaceModelV2(
userFaceId = obj.get("userFaceId")?.takeIf { !it.isJsonNull }?.asString,
userId = obj.get("userId")?.takeIf { !it.isJsonNull }?.asString,
faceFeature = obj.get("faceFeature")?.takeIf { !it.isJsonNull }?.asString,
faceFeatureStr = null,
faceFeatureString = null,
faceUpdateTimestamp = timestamp,
cardNo = obj.get("cardNo")?.takeIf { !it.isJsonNull }?.asString,
member = obj.get("member")?.takeIf { !it.isJsonNull }?.asBoolean,
faceDeleted = obj.get("faceDeleted")?.takeIf { !it.isJsonNull }?.asBoolean ?: false,
personType = obj.get("personType")?.takeIf { !it.isJsonNull }?.asString
)
}
}
/**
* 应用 MQTT 实时推送的人脸变更(立即落库,不等下一轮轮询)
*
* 处理语义与增量接口一致,另加乱序守卫:
* 按 userFaceId 查本地记录,消息时间戳不大于本地时间戳的条目直接丢弃,
* 防止 QoS1 重复投递或乱序到达时旧数据覆盖新数据。
*
* 与 HTTP 增量轮询通过 [FaceSyncLock] 串行化写库,避免同一 userFaceId 重复入库。
*
* 注意:本方法不推进增量水位。水位只由 HTTP 增量接口的响应推进,
* 后台推送失败只记日志不重发,若 MQ 消息把水位推到漏发变更之后,
* 轮询/补拉将永远拉不到那条变更。
*/
private suspend fun applyRealtimeUpdates(items: List<UserFaceModelV2>) {
if (items.isEmpty()) return
var changed = false
FaceSyncLock.mutex.withLock {
// 批内去重:同一 userFaceId 仅保留时间戳最大的一条(含其删除标志)
val batch = items.groupBy { it.userFaceId }
.flatMap { (key, list) ->
if (key.isNullOrEmpty()) list
else listOfNotNull(list.maxByOrNull { it.faceUpdateTimestamp ?: 0L })
}
// 乱序守卫:本地已有同 userFaceId 且时间戳不旧的记录则跳过该条
val fresh = batch.filter { msg ->
val key = msg.userFaceId
if (key.isNullOrEmpty()) return@filter true
val local = faceApi.queryByUserFaceId(key)
val msgTs = msg.faceUpdateTimestamp ?: 0L
when {
local == null -> true
msgTs <= 0L -> true
msgTs > local.faceUpdateTimestamp -> true
else -> {
Timber.d("$TAG 实时消息乱序/重复,丢弃 userFaceId=$key ts=$msgTs")
false
}
}
}
if (fresh.isEmpty()) return@withLock
for (msg in fresh) {
if (msg.faceDeleted == true) {
val userFaceId = msg.userFaceId
if (!userFaceId.isNullOrEmpty() && faceApi.queryByUserFaceId(userFaceId) != null) {
faceApi.deleteByUserFaceId(userFaceId)
changed = true
}
} else {
val entity = buildEntity(msg) ?: continue
val userFaceId = msg.userFaceId
if (!userFaceId.isNullOrEmpty()) {
// 优先按 userFaceId 精确判重,避免特征字段不一致导致重复入库
if (faceApi.queryByUserFaceId(userFaceId) != null) continue
faceApi.insert(entity)
} else {
// userFaceId 为空时回退到特征判重(与 HTTP 增量逻辑对齐)
val featureStr = msg.resolveFeatureStr()
val existList = faceApi.queryAllByUserName(msg.userId)
val alreadyExists = existList.any { e ->
featureStr != null && Base64.encode(e.featureData) == featureStr
}
if (!alreadyExists) faceApi.insert(entity)
}
changed = true
}
}
}
if (changed) {
Timber.i("$TAG MQTT 实时更新完成 ${items.size} 条(实际变更 $changed 条),通知界面刷新引擎")
EventBus.getDefault().post(FaceChangedEvent())
}
}
/** 将 MQTT 消息转换为 [FaceEntity],特征 Base64 为空或解码失败返回 null */
private fun buildEntity(model: UserFaceModelV2): FaceEntity? {
val featureBase64 = model.resolveFeatureStr()
if (featureBase64.isNullOrBlank()) {
Timber.w("$TAG 人脸特征为空,跳过入库: userFaceId=${model.userFaceId} userId=${model.userId}")
return null
}
return try {
model.toFaceEntity()
} catch (e: Exception) {
Timber.e(e, "$TAG 人脸特征 Base64 解码失败: userFaceId=${model.userFaceId}")
null
}
}
}
@@ -0,0 +1,14 @@
package com.sw.platecabinet.mqtt
import kotlinx.coroutines.sync.Mutex
/**
* 本地人脸库写操作的进程级互斥锁。
*
* MQTT 实时更新([FaceMqttSubscriber])与 HTTP 增量轮询(NetViewModelV2.getFaceIncrementList
* 会并发写本地人脸库。二者对同一 userFaceId 的「判重 → 插入」不是原子操作,
* 并发时会产生重复记录;共享此锁将两路写入串行化。
*/
object FaceSyncLock {
val mutex = Mutex()
}
@@ -0,0 +1,30 @@
package com.sw.platecabinet.mqtt
/**
* MQTT 连接配置
*
* @param host Broker 主机地址
* @param port Broker 端口,默认 1883
* @param scheme 连接协议:tcp(裸 MQTT)、wsMQTT over WebSocket)、wssWebSocket + TLS)。
* EMQX 惯例 1883=tcp / 8083=ws / 8084=wss,端口与协议不匹配时 Broker 会在
* CONNACK 前直接断开(32109/EOFException
* @param clientId 客户端唯一标识;同一 Broker 下多台设备禁止重复,否则旧连接会被踢下线
* @param userName 鉴权用户名,null 表示匿名连接
* @param password 鉴权密码,null 表示无密码
* @param cleanSession false 时保留会话(离线期间 QoS1 消息由 Broker 暂存,重连后补收)
* @param keepAliveInterval 心跳间隔(秒)
* @param connectionTimeout 连接超时(秒)
*/
data class MqttConfig(
val host: String,
val port: Int = 1883,
val scheme: String = "tcp",
val clientId: String? = null,
val userName: String? = null,
val password: String? = null,
val cleanSession: Boolean = true,
val keepAliveInterval: Int = 20,
val connectionTimeout: Int = 30
) {
val brokerUrl: String get() = "$scheme://$host:$port"
}
@@ -0,0 +1,274 @@
package com.sw.platecabinet.mqtt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken
import org.eclipse.paho.client.mqttv3.MqttCallbackExtended
import org.eclipse.paho.client.mqttv3.MqttClient
import org.eclipse.paho.client.mqttv3.MqttConnectOptions
import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence
import timber.log.Timber
/** MQTT 连接状态 */
enum class MqttState { Disconnected, Connecting, Connected }
/**
* MQTT 连接管理器(进程级单例,替代 Hilt 注入)
*
* 负责 Broker 连接、自动重连、订阅管理与消息分发:
* - 启用 Paho 自动重连,重连成功后自动恢复历史订阅([MqttCallbackExtended.connectComplete]
* - 业务消息通过 [messages] SharedFlow 分发,业务层按 topic 过滤消费
* - cleanSession=false 时 Broker 会暂存离线期间的 QoS1 消息,重连后补收
*/
object MqttManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var client: MqttClient? = null
private var config: MqttConfig? = null
/** 已订阅的 topic 及 QoS,自动重连成功后按此恢复订阅 */
private val subscriptions = linkedMapOf<String, Int>()
private val _state = MutableStateFlow(MqttState.Disconnected)
val state: StateFlow<MqttState> = _state.asStateFlow()
private val _messages = MutableSharedFlow<MqttMessage>(extraBufferCapacity = 20)
val messages: SharedFlow<MqttMessage> = _messages.asSharedFlow()
/** 最近一次连接成功时间(毫秒时间戳,null 表示从未连上),供运维面板展示 */
private val _lastConnectedAt = MutableStateFlow<Long?>(null)
val lastConnectedAt: StateFlow<Long?> = _lastConnectedAt.asStateFlow()
/** 最近一次连接丢失时间(毫秒时间戳) */
private val _lastLostAt = MutableStateFlow<Long?>(null)
val lastLostAt: StateFlow<Long?> = _lastLostAt.asStateFlow()
/** 最近一次连接/订阅/发布错误信息 */
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _lastError.asStateFlow()
/** 最近一次收到业务消息时间(毫秒时间戳),用于判断"Connected 但链路假活" */
private val _lastMessageArrivedAt = MutableStateFlow<Long?>(null)
val lastMessageArrivedAt: StateFlow<Long?> = _lastMessageArrivedAt.asStateFlow()
/** 累计连接成功 / 断开次数(用于判断闪断) */
private val _connectCount = MutableStateFlow(0)
val connectCount: StateFlow<Int> = _connectCount.asStateFlow()
private val _disconnectCount = MutableStateFlow(0)
val disconnectCount: StateFlow<Int> = _disconnectCount.asStateFlow()
/** 当前 Broker URL(未配置返回 null */
val brokerUrl: String? get() = config?.brokerUrl
/** 当前 clientId(未配置返回 null */
val clientId: String? get() = config?.clientId
/** 已订阅主题与 QoS 的线程安全快照 */
fun getSubscriptions(): Map<String, Int> = synchronized(subscriptions) { subscriptions.toMap() }
/** 首连失败后的退避重试协程,连接成功或主动断开时取消 */
private var retryJob: Job? = null
fun configure(cfg: MqttConfig) {
config = cfg
Timber.d("MQTT 配置完成: ${cfg.brokerUrl}")
}
fun connect() {
val cfg = config ?: run {
Timber.e("MQTT 未配置,请先调用 configure()")
return
}
// 已连接或正在连接时重复调用直接忽略,避免重建客户端导致重复连接
if (_state.value != MqttState.Disconnected) {
Timber.d("MQTT 当前状态=${_state.value},忽略重复 connect()")
return
}
retryJob?.cancel()
retryJob = scope.launch { doConnect(cfg) }
}
/**
* 建立连接,失败则指数退避重试
*
* Paho 的 automaticReconnect 仅在首连成功后生效;设备开机时 Broker 可能
* 尚未就绪导致首连失败,故此处自建退避循环兜底(1s 起、翻倍、60s 封顶)。
*/
private suspend fun doConnect(cfg: MqttConfig) {
val id = cfg.clientId ?: "platecabinet_${System.currentTimeMillis()}"
var backoffMs = 1_000L
while (true) {
try {
_state.value = MqttState.Connecting
client = MqttClient(cfg.brokerUrl, id, MemoryPersistence()).apply {
setCallback(createCallback())
val options = MqttConnectOptions().apply {
isCleanSession = cfg.cleanSession
keepAliveInterval = cfg.keepAliveInterval
connectionTimeout = cfg.connectionTimeout
isAutomaticReconnect = true
maxReconnectDelay = 60_000
// 鉴权信息(用户名非空时才注入,避免空串覆盖匿名连接)
cfg.userName?.let { userName = it }
cfg.password?.let { password = it.toCharArray() }
}
connect(options)
}
_state.value = MqttState.Connected
_lastConnectedAt.value = System.currentTimeMillis()
Timber.i("MQTT 连接成功: ${cfg.brokerUrl}, clientId=$id, cleanSession=${cfg.cleanSession}")
return
} catch (e: Exception) {
// 连接失败时 MqttClient 可能残留半初始化状态,先关闭清理
try { client?.close() } catch (_: Exception) {}
client = null
_state.value = MqttState.Disconnected
_lastError.value = "连接失败: ${e.message}"
_messages.emit(MqttMessage.Error("连接失败: ${e.message}", e))
Timber.e(e, "MQTT 连接失败,${backoffMs / 1000} 秒后重试")
delay(backoffMs)
backoffMs = (backoffMs * 2).coerceAtMost(60_000L)
}
}
}
fun disconnect() {
// 取消首连重试循环,避免主动断开后仍反复重连
retryJob?.cancel()
retryJob = null
scope.launch {
try { client?.disconnect() } catch (_: Exception) {}
client?.close()
client = null
_state.value = MqttState.Disconnected
_messages.emit(MqttMessage.Disconnected())
Timber.d("MQTT 已断开")
}
}
/** 订阅主题(记录 QoS,自动重连成功后由 [createCallback] 恢复) */
fun subscribe(topic: String, qos: Int = 0) {
synchronized(subscriptions) { subscriptions[topic] = qos }
scope.launch {
// 未连接时仅记录订阅关系,待连接成功后由 connectComplete 恢复,
// 不发 Subscribed 事件以免误报"订阅成功"
val current = client ?: run {
Timber.d("MQTT 未连接,订阅 $topic 已记录,连接成功后自动恢复")
return@launch
}
try {
// subscribe 返回 Broker 实际授予的 QoS(0x80 表示被拒绝),用于联调排查
current.subscribe(topic, qos)
_messages.emit(MqttMessage.Subscribed(topic))
Timber.i("MQTT 订阅成功: $topic qos=$qos")
} catch (e: Exception) {
_messages.emit(MqttMessage.Error("订阅失败: ${e.message}", e))
_lastError.value = "订阅失败: ${e.message}"
}
}
}
fun publish(topic: String, payload: ByteArray, qos: Int = 0, retain: Boolean = false) {
scope.launch {
try {
client?.publish(topic, payload, qos, retain)
_messages.emit(MqttMessage.Published(topic))
} catch (e: Exception) {
_messages.emit(MqttMessage.Error("发布失败: ${e.message}", e))
_lastError.value = "发布失败: ${e.message}"
}
}
}
/**
* 手动重连(运维面板用):单个协程内先断开清理、状态归位,再复用 [doConnect] 的退避重试。
* 区别于 [connect]——后者在非 Disconnected 状态会直接忽略,无法用于"已连接但需强制重连"。
*/
fun reconnect() {
retryJob?.cancel()
retryJob = scope.launch {
val cfg = config ?: run {
_lastError.value = "MQTT 未配置,无法重连"
Timber.e("MQTT 未配置,请先调用 configure()")
return@launch
}
try { client?.disconnect() } catch (_: Exception) {}
try { client?.close() } catch (_: Exception) {}
client = null
_state.value = MqttState.Disconnected
_messages.emit(MqttMessage.Disconnected())
doConnect(cfg)
}
}
fun destroy() {
disconnect()
scope.cancel()
}
/** 使用 Extended 回调:connectComplete 在首次连接与自动重连成功时都会回调,用于恢复订阅 */
private fun createCallback() = object : MqttCallbackExtended {
override fun connectComplete(reconnect: Boolean, serverURI: String?) {
scope.launch {
_state.value = MqttState.Connected
_lastConnectedAt.value = System.currentTimeMillis()
_connectCount.value += 1
Timber.i("MQTT connectComplete: reconnect=$reconnect serverURI=$serverURI")
// 重连后恢复历史订阅(cleanSession=false 时 Broker 已保留订阅,重复订阅幂等)
val restore = synchronized(subscriptions) { subscriptions.toMap() }
if (restore.isNotEmpty()) {
try {
client?.subscribe(restore.keys.toTypedArray(), restore.values.toIntArray())
Timber.i("MQTT ${if (reconnect) "重连" else "连接"}完成,已恢复订阅 ${restore.size} 个: ${restore.keys}")
} catch (e: Exception) {
Timber.e(e, "MQTT 恢复订阅失败")
}
} else {
Timber.w("MQTT 连接完成但无历史订阅(尚未调用过 subscribe)")
}
_messages.emit(MqttMessage.Connected)
}
}
override fun connectionLost(cause: Throwable?) {
scope.launch {
_state.value = MqttState.Disconnected
_lastLostAt.value = System.currentTimeMillis()
_lastError.value = cause?.message ?: "未知原因(可能是 Broker 踢线/网络中断)"
_disconnectCount.value += 1
_messages.emit(MqttMessage.Disconnected(cause))
// cause 可能为 null(如 Broker 主动踢 clientId 重复时仅表现为静默断开),
// 打印消息体帮助定位掉线原因
Timber.w(cause, "MQTT 连接丢失: ${cause?.message ?: "无异常信息(可能是 Broker 踢线/网络中断)"}")
}
}
override fun messageArrived(topic: String?, message: org.eclipse.paho.client.mqttv3.MqttMessage?) {
if (topic != null && message != null) {
_lastMessageArrivedAt.value = System.currentTimeMillis()
Timber.i(
"MQTT messageArrived: topic=$topic size=${message.payload.size} " +
"qos=${message.qos} dup=${message.isDuplicate} retained=${message.isRetained}"
)
scope.launch {
_messages.emit(MqttMessage.Received(topic, message.payload))
}
} else {
Timber.w("MQTT messageArrived 收到空消息: topic=$topic message=$message")
}
}
override fun deliveryComplete(token: IMqttDeliveryToken?) {}
}
}
@@ -0,0 +1,11 @@
package com.sw.platecabinet.mqtt
/** MQTT 事件消息密封类 */
sealed class MqttMessage {
data object Connected : MqttMessage()
data class Disconnected(val cause: Throwable? = null) : MqttMessage()
data class Received(val topic: String, val payload: ByteArray) : MqttMessage()
data class Subscribed(val topic: String) : MqttMessage()
data class Published(val topic: String) : MqttMessage()
data class Error(val message: String, val throwable: Throwable? = null) : MqttMessage()
}
@@ -3,6 +3,7 @@ package com.sw.platecabinet.network
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.MyApp
import com.sw.platecabinet.network.api.ApiService
import com.sw.platecabinet.network.api.ApiServiceV2
import com.sw.platecabinet.network.interceptor.RequestInterceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
@@ -12,36 +13,42 @@ import timber.log.Timber
import java.util.concurrent.TimeUnit
object ApiClient {
//private const val BASE_URL = "https://vip.shuziweidao.com"
// private const val DEVICE_BASE_URL = "http://device.shuziweidao.com:8889/"
private var DEVICE_BASE_URL = GlobalData.appBaseUrl
// private const val BASE_URL = "http://192.168.1.8:9092"
private const val TIME_OUT = 30L // 超时时间(秒)
val okHttpClient = OkHttpClient.Builder()
private const val TIME_OUT = 60L // 超时时间(秒)
private val okHttpClient = OkHttpClient.Builder()
.connectTimeout(TIME_OUT, TimeUnit.SECONDS)
.readTimeout(TIME_OUT, TimeUnit.SECONDS)
.writeTimeout(TIME_OUT, TimeUnit.SECONDS)
.addNetworkInterceptor(HttpLoggingInterceptor(logger = {
Timber.d("okhttp logger ==>${it}")
}).apply {
level = if (MyApp.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
.addNetworkInterceptor(HttpLoggingInterceptor { Timber.d("okhttp ==>${it}") }.apply {
level = if (MyApp.DEBUG) HttpLoggingInterceptor.Level.BODY
else HttpLoggingInterceptor.Level.NONE
})
.addInterceptor(RequestInterceptor())
.build()
private val retrofit = Retrofit.Builder()
.baseUrl(DEVICE_BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
// .addCallAdapterFactory(CoroutineCallAdapterFactory()) // 协程适配器
.build()
private val retrofit by lazy {
Retrofit.Builder()
.baseUrl(resolveBaseUrl())
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
val apiService: ApiService by lazy {
retrofit.create(ApiService::class.java)
}
}
val apiServiceV2: ApiServiceV2 by lazy {
retrofit.create(ApiServiceV2::class.java)
}
/**
* 规范化 Retrofit 要求的 baseUrl:非空且以 / 结尾。
* 所有接口走 @Url 全路径,此值不参与实际拼接,只需合法。
*/
private fun resolveBaseUrl(): String {
val url = GlobalData.appBaseUrl.ifBlank { "http://localhost/" }
return if (url.endsWith("/")) url else "$url/"
}
}
@@ -4,7 +4,6 @@ import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.model.DeviceConfig
import com.sw.platecabinet.model.request.BindParam
import com.sw.platecabinet.model.request.EquipmentParam
import com.sw.platecabinet.model.request.LoginParam
import com.sw.platecabinet.model.request.SearchParam
import com.sw.platecabinet.model.response.ApiResponse
import com.sw.platecabinet.model.response.EquipmentInfo
@@ -12,8 +11,6 @@ import com.sw.platecabinet.model.response.EquipmentUserInfo
import com.sw.platecabinet.model.response.SearchResult
import com.sw.platecabinet.model.response.UserFaceModel
import retrofit2.http.Body
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
@@ -75,13 +72,13 @@ interface ApiService {
// @Body param: LoginParam
// ): ApiResponse<EquipmentUserInfo>
/**
* 餐盘用户信息获取
/**
* 通过用户ID获取信息(人脸识别匹配到 userId 后调用,替代旧 getPlateBoxUserInfo 登录接口)
*/
@POST//("/shuwei-zhct/swEquipmentRelUser/equipmentBoxLogin")
suspend fun equipmentBoxLogin(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/sideboard/app/getPlateBoxUserInfo",
@Body param: LoginParam
@GET
suspend fun getMemberRefPlateByUserId(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/sideboard/app/getMemberRefPlateByUserId",
@Query("userId") userId: String
): ApiResponse<EquipmentUserInfo>
/**
@@ -95,7 +92,7 @@ interface ApiService {
@GET
suspend fun getEquipmentList(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/sideboard/app/getYxMemberRefPlateByEquipmentCode",
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/sideboard/app/getYxMemberRefPlateByEquipmentCode",
): ApiResponse<List<EquipmentUserInfo>>
// /**
// * 餐盘用户信息绑定解绑
@@ -111,18 +108,17 @@ interface ApiService {
*/
@POST
suspend fun plateBind(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/sideboard/app/plateBinding",
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/sideboard/app/plateBinding",
@Body param: BindParam
): ApiResponse<EquipmentUserInfo?>
/**
* 餐盘解绑
* 餐盘解绑JSON 请求体)
*/
@POST
@FormUrlEncoded
suspend fun plateUnbind(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/sideboard/app/plateUnbind",
@Field("id") id: Long?
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/sideboard/app/plateUnbind",
@Body param: Map<String, String>
): ApiResponse<Any?>
/**
@@ -131,7 +127,7 @@ interface ApiService {
@POST//("/shuwei-user/swclientUserInfoShop/selectList")
suspend fun searchUser(
// @Url url: String = "${GlobalData.appBaseUrl}/shuwei-user/swclientUserInfoShop/selectList",
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/common/app/getUserInfoByNameOrPhone",
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/common/app/getUserInfoByNameOrPhone",
@Body param: SearchParam
): ApiResponse<List<SearchResult.Member>?>
@@ -150,7 +146,7 @@ interface ApiService {
*/
@GET
suspend fun findByPlateNumber(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/sideboard/app/getMemberRefPlateByPlateNumber",
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/sideboard/app/getMemberRefPlateByPlateNumber",
@Query("plateNumber") plateNumber: String
): ApiResponse<EquipmentUserInfo>
@@ -0,0 +1,78 @@
package com.sw.platecabinet.network.api
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.model.DeviceConfigV2
import com.sw.platecabinet.model.request.FeatureBody
import com.sw.platecabinet.model.request.IdDTO
import com.sw.platecabinet.model.response.ApiResponse
import com.sw.platecabinet.model.response.UserFaceModel
import com.sw.platecabinet.model.response.UserFaceModelV2
import okhttp3.MultipartBody
import retrofit2.http.Body
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Url
interface ApiServiceV2 {
/**
* 获取人脸缓存数据
*/
@POST
suspend fun getUserFaceCache(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/common/face/page",
@Body param: Map<String, Int>
): ApiResponse<List<UserFaceModelV2>>
/**
* 获取人脸增量数据
*/
@POST
suspend fun getFaceIncrementList(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/common/face/increment",
@Body param: Map<String, Long>
): ApiResponse<List<UserFaceModelV2>?>
/**
* 获取设备配置数据
*/
@GET
suspend fun getDeviceConfig(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/pickup/device/config"
): ApiResponse<DeviceConfigV2?>
/**
* 添加人脸数据
*/
@POST
suspend fun addUserFace(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/user/add-by-face",
@Body faceData: FeatureBody
): ApiResponse<UserFaceModelV2?>
/**
* 上传图片
*/
@Multipart
@POST
suspend fun uploadImage(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/upload",
@Part file: MultipartBody.Part,
): ApiResponse<String?>
/**
* 取餐盘(P-09a):记录用户开始就餐时刻
* 调用一次即 upsert 到主单 plate_pickup_time,是 P-09 提交就餐记录的强前置。
* @param param IdDTOid 为用户 id(餐盘号 = userId
* @return ApiResponse<String?>data 为主单 recordNo
*/
@POST
suspend fun platePickup(
@Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/pickup/plate-pickup",
@Body param: IdDTO
): ApiResponse<String?>
}
@@ -1,23 +1,11 @@
package com.sw.platecabinet.network.task
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.arcsoft.face.FaceEngine
import com.sw.inbound.utils.GsonUtils
import com.sw.plate.App
import com.sw.plate.utils.Base64
import com.sw.plate.utils.PrefUtils
import com.sw.plate.utils.arcface.FaceApi
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import com.sw.plate.utils.arcface.faceserver.FaceServer
import com.sw.platecabinet.network.ApiClient
import com.sw.platecabinet.repository.RemoteRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.EventBus
import timber.log.Timber
class HeartBeatTask(appContext: Context, workerParams: WorkerParameters) :
CoroutineWorker(appContext, workerParams) {
@@ -1,13 +1,9 @@
package com.sw.platecabinet.repository
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.model.DeviceConfig
import com.sw.platecabinet.model.request.BindParam
import com.sw.platecabinet.model.request.EquipmentParam
import com.sw.platecabinet.model.request.LoginParam
import com.sw.platecabinet.model.request.SearchParam
import com.sw.platecabinet.model.response.ApiResponse
import com.sw.platecabinet.model.response.EquipmentInfo
import com.sw.platecabinet.model.response.EquipmentUserInfo
import com.sw.platecabinet.model.response.SearchResult
import com.sw.platecabinet.model.response.UserFaceModel
@@ -74,10 +70,10 @@ class RemoteRepository constructor(
}
/**
* 登录
* 通过用户ID获取信息(人脸识别匹配到 userId 后调用)
*/
suspend fun equipmentBoxLogin(param: LoginParam): ApiResponse<EquipmentUserInfo> {
return safeApiCall { apiService.equipmentBoxLogin(param = param) }
suspend fun getMemberRefPlateByUserId(userId: String?): ApiResponse<EquipmentUserInfo> {
return safeApiCall { apiService.getMemberRefPlateByUserId(userId = userId ?: "") }
}
/**
@@ -98,8 +94,8 @@ class RemoteRepository constructor(
return safeApiCall { apiService.plateBind(param = param) }
}
suspend fun plateUnbind(id: Long?): ApiResponse<Any?> {
return safeApiCall { apiService.plateUnbind(id = id) }
suspend fun plateUnbind(id: String?): ApiResponse<Any?> {
return safeApiCall { apiService.plateUnbind(param = mapOf("id" to (id ?: ""))) }
}
/**
@@ -0,0 +1,82 @@
package com.sw.platecabinet.repository
import com.sw.platecabinet.model.DeviceConfigV2
import com.sw.platecabinet.model.request.FeatureBody
import com.sw.platecabinet.model.request.IdDTO
import com.sw.platecabinet.model.response.ApiResponse
import com.sw.platecabinet.model.response.UserFaceModel
import com.sw.platecabinet.model.response.UserFaceModelV2
import com.sw.platecabinet.network.api.ApiServiceV2
import com.sw.platecabinet.utils.FileUtils
import java.io.File
/**
* 远程数据处理
*/
class RemoteRepositoryV2 constructor(
private val apiService: ApiServiceV2
) : BaseRepository() {
/**
* 获取人脸数据
*/
suspend fun getUserFaceCache(
pageNum: Int,
pageSize: Int = 500,
): ApiResponse<List<UserFaceModelV2>> {
return safeApiCall {
apiService.getUserFaceCache(
param = mapOf(
"pageNum" to pageNum, "pageSize" to pageSize
)
)
}
}
suspend fun getFaceIncrementList(
pageNum: Long, pageSize: Long = 100L, timestamp: Long
): ApiResponse<List<UserFaceModelV2>?> {
return safeApiCall {
apiService.getFaceIncrementList(
param = mapOf(
"pageNum" to pageNum, "pageSize" to pageSize, "timestamp" to timestamp
)
)
}
}
suspend fun getDeviceConfig(): ApiResponse<DeviceConfigV2?> {
return safeApiCall { apiService.getDeviceConfig() }
}
suspend fun addUserFace(faceData: String, imageUrl: String): ApiResponse<UserFaceModelV2?> {
return safeApiCall {
apiService.addUserFace(
faceData = FeatureBody(
url = imageUrl, featureChar = faceData
)
)
}
}
suspend fun uploadImage(file: File): ApiResponse<String?> {
return safeApiCall {
val part = FileUtils.genRequestPart(file)
if (part == null) {
ApiResponse(code = "-1", msg = "解析图片失败")
} else {
apiService.uploadImage(file = part)
}
}
}
/**
* 取餐盘(P-09a):上报用户开始就餐时刻
* @param userId 用户 id(餐盘号 = userId
* @return ApiResponse<String?>data 为主单 recordNo
*/
suspend fun platePickup(userId: Long): ApiResponse<String?> {
return safeApiCall { apiService.platePickup(param = IdDTO(id = userId)) }
}
}
@@ -0,0 +1,61 @@
package com.sw.platecabinet.utils
import android.Manifest
import android.app.ActivityManager
import android.content.Context
import android.content.pm.PackageManager
import android.hardware.camera2.CameraManager
import android.os.StatFs
import android.os.SystemClock
import androidx.core.content.ContextCompat
/**
* 设备健康只读信息:运行时长、内存、内部存储。
*/
object DeviceInfoProvider {
/** 进程运行时长(工控机 App 常驻,近似设备运行时长),格式「x小时x分」 */
fun uptime(): String {
val ms = SystemClock.elapsedRealtime()
val days = ms / (24 * 3600 * 1000L)
val hours = (ms % (24 * 3600 * 1000L)) / (3600 * 1000L)
val minutes = (ms % (3600 * 1000L)) / (60 * 1000L)
return if (days > 0) "${days}${hours}小时${minutes}" else "${hours}小时${minutes}"
}
/** 内存摘要:可用/总(MB) */
fun memorySummary(context: Context): String {
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager ?: return "未知"
val mi = ActivityManager.MemoryInfo()
am.getMemoryInfo(mi)
val avail = mi.availMem / (1024 * 1024)
val total = mi.totalMem / (1024 * 1024)
val low = if (mi.lowMemory) "(低内存告警)" else ""
return "可用 ${avail}MB / 总 ${total}MB$low"
}
/** 内部存储剩余(日志与人脸库都落在 filesDir,满盘是隐形故障源),格式「可用/总 MB」 */
fun internalStorage(context: Context): String {
return try {
val stat = StatFs(context.filesDir.absolutePath)
val total = stat.totalBytes / (1024 * 1024)
val avail = stat.availableBytes / (1024 * 1024)
"可用 ${avail}MB / 总 ${total}MB"
} catch (e: Exception) {
"未知"
}
}
/** 摄像头状态:系统识别到的摄像头数量 + 相机权限是否授予 */
fun cameraSummary(context: Context): String {
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED
val count = try {
val cm = context.getSystemService(Context.CAMERA_SERVICE) as? CameraManager
cm?.cameraIdList?.size ?: 0
} catch (e: Exception) {
0
}
return "摄像头: $count 个 | 权限: ${if (granted) "已授予" else "未授予"}"
}
}
@@ -0,0 +1,135 @@
package com.sw.platecabinet.utils
import android.content.Context
import com.sw.plate.utils.arcface.FaceApi
import com.sw.plate.utils.comn.SerialApi
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.mqtt.MqttManager
import timber.log.Timber
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* 诊断包导出:把设备信息、运行日志、崩溃日志打包成 zip。
* 优先写 U 盘根目录;U 盘不可用或写入失败时兜底到应用 filesDir/diagnostic/。
* 工控机现场无微信等分享应用,故不走 FileProvider 分享。
*/
object DiagnosticExporter {
/** 导出结果 */
data class ExportResult(val success: Boolean, val message: String, val file: File? = null)
private val faceApi = FaceApi()
/**
* 导出诊断包(在 IO 线程调用):
* 1. 优先写 U 盘根目录;
* 2. U 盘不可用或写入失败时,兜底到应用 filesDir/diagnostic/,提示中给出绝对路径(便于 adb pull)。
*/
fun export(context: Context): ExportResult {
// 1. 优先尝试 U 盘
UsbStorageHelper.findUsbDir(context)?.let { usbDir ->
try {
val file = buildZip(context, usbDir)
return ExportResult(true, "已导出到 U 盘: ${file.name}", file)
} catch (_: Exception) {
// U 盘写入失败,落入应用目录兜底
}
}
// 2. 兜底:应用目录(仅保留最新一份,避免累积占用内部存储)
val appDir = File(context.filesDir, "diagnostic").apply { mkdirs() }
appDir.listFiles { f -> f.isFile && f.name.endsWith(".zip") }?.forEach { it.delete() }
return try {
val file = buildZip(context, appDir)
ExportResult(true, "U 盘不可用,已导出到应用目录:\n${file.absolutePath}", file)
} catch (e: Exception) {
ExportResult(false, "导出失败: ${e.message}")
}
}
/** 生成诊断 zip 并写入目标目录 */
private fun buildZip(context: Context, outDir: File): File {
val time = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val zipFile = File(outDir, "diagnostic_$time.zip")
Timber.d("Building zip file: ${zipFile.absolutePath}")
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
// 1. 设备快照 info.txt
zos.putNextEntry(ZipEntry("info.txt"))
zos.write(buildInfo(context).toByteArray(Charsets.UTF_8))
zos.closeEntry()
// 2. 运行日志
LogFileManager.getLogFiles(context).forEach { file ->
addFile(zos, file, "logs/${file.name}")
}
// 3. 崩溃日志
CrashHandler.getCrashReportFiles(context).forEach { file ->
addFile(zos, file, "crash/${file.name}")
}
}
return zipFile
}
/** 设备信息 + 网络 + MQTT + 人脸 + 设备健康 纯文本快照 */
private fun buildInfo(context: Context): String {
val mqttState = MqttManager.state.value.name
val subs = MqttManager.getSubscriptions().entries
.joinToString("; ") { "${it.key}(qos=${it.value})" }
.ifBlank { "" }
val time = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())
val lastCrash = CrashHandler.getCrashReportFiles(context)
.maxOfOrNull { it.lastModified() }
?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(it)) }
?: ""
val usbDir = UsbStorageHelper.findUsbDir(context)?.absolutePath ?: "未检测"
return buildString {
appendLine("========== 诊断快照 ==========")
appendLine("生成时间: $time")
appendLine("设备ID: ${GlobalData.deviceId}")
appendLine("设备编号: ${GlobalData.globalEquipmentCode}")
appendLine("应用版本: ${GlobalData.appVersion}")
appendLine()
appendLine("--- 设备健康 ---")
appendLine("运行时长: ${DeviceInfoProvider.uptime()}")
appendLine("内存: ${DeviceInfoProvider.memorySummary(context)}")
appendLine("存储: ${DeviceInfoProvider.internalStorage(context)}")
appendLine(DeviceInfoProvider.cameraSummary(context))
appendLine("串口: ${SerialApi.getPath()} @ ${SerialApi.getBaudRate()} | ${if (SerialApi.isOpened()) "已打开" else "未打开"}")
appendLine("U盘: $usbDir")
appendLine("最近崩溃: $lastCrash")
appendLine()
appendLine("--- 网络 ---")
appendLine("连通性: ${NetStatusProvider.connectivitySummary(context)}")
appendLine("本机IP: ${NetStatusProvider.localIpv4()}")
appendLine("环境: ${NetStatusProvider.envName(GlobalData.appBaseUrl)}")
appendLine("BaseUrl: ${GlobalData.appBaseUrl}")
appendLine()
appendLine("--- MQTT ---")
appendLine("状态: $mqttState")
appendLine("Broker: ${MqttManager.brokerUrl ?: "未配置"}")
appendLine("clientId: ${MqttManager.clientId ?: "未配置"}")
appendLine("订阅: $subs")
appendLine("连接次数: ${MqttManager.connectCount.value} / 断开次数: ${MqttManager.disconnectCount.value}")
appendLine("最近错误: ${MqttManager.lastError.value ?: "—"}")
appendLine()
appendLine("--- 人脸库 ---")
appendLine("总数: ${faceApi.queryFaceCount()}")
appendLine("会员: ${faceApi.queryFaceCountByMember(true)} / 非会员: ${faceApi.queryFaceCountByMember(false)}")
appendLine("同步水位: ${SpTool.getLastFaceTimestamp()}")
appendLine("库内最大更新时间: ${faceApi.queryMaxFaceUpdateTimestamp() ?: "—"}")
}
}
private fun addFile(zos: ZipOutputStream, file: File, entryName: String) {
zos.putNextEntry(ZipEntry(entryName))
file.inputStream().use { input -> input.copyTo(zos) }
zos.closeEntry()
}
}
@@ -0,0 +1,64 @@
package com.sw.platecabinet.utils
import android.app.Activity
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Process
import com.sw.plate.utils.arcface.facedb.FaceDatabase
import com.sw.platecabinet.Environment
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.GlobalKey
/**
* 环境切换:落盘 baseUrl + 清空本地人脸库 + 重置增量水位 + 自动重启。
* 供运维面板复用(登录页连点切换的那套逻辑与此等价)。
*/
object EnvironmentSwitcher {
/**
* 切换业务环境。
* @param activity 用于写 SharedPreferences / 取 applicationContext / 切主线程
* @param env 目标环境
* @param onRestarting 重启前回调(主线程,用于 Toast 提示)
*/
fun switch(activity: Activity, env: Environment, onRestarting: () -> Unit) {
// 用 commit 同步写盘,确保自动重启前状态已持久化(apply 是异步的,重启会丢)
activity.getSharedPreferences("default_sp", Context.MODE_PRIVATE).edit()
.putString(GlobalKey.KEY_BASE_URL, env.url)
.putLong(SpTool.LAST_FACE_TIMESTAMP, 0L)
.putBoolean(SpTool.IS_FIRST_GET_FACE, true)
.commit()
GlobalData.appBaseUrl = env.url
// Room 同步访问不能在主线程,后台清库后回主线程重启
Thread {
try {
val faceDao = FaceDatabase.getInstance(activity.applicationContext).faceDao()
faceDao.deleteAll()
faceDao.resetId()
} catch (_: Exception) {
// 清库失败不阻断切换,重启后首次全量同步会兜底
}
activity.runOnUiThread {
onRestarting()
restart(activity)
}
}.start()
}
/** 用 AlarmManager 拉起启动页后杀进程,实现应用自重启 */
private fun restart(context: Context) {
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
} ?: return
val pending = PendingIntent.getActivity(
context, 0, intent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmManager.set(AlarmManager.RTC, System.currentTimeMillis() + 500, pending)
Process.killProcess(Process.myPid())
}
}
@@ -0,0 +1,83 @@
package com.sw.platecabinet.utils
import android.content.Context
import android.util.Log
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
/**
* 运行时日志落盘:写入 filesDir/logs/log-YYYYMMDD.txt,按天滚动、单文件超 5MB 切分、保留 7 天。
* 与 Timber.DebugTree 并存,专供运维页查看与导出。
*/
class FileLoggingTree(private val context: Context) : Timber.Tree() {
companion object {
private const val LOG_DIR = "logs"
private const val MAX_FILE_SIZE = 5L * 1024 * 1024
private const val MAX_AGE_DAYS = 7L
fun logDir(context: Context): File = File(context.filesDir, LOG_DIR)
}
// 单线程串行写盘,避免多线程并发写同一文件导致行交错;写盘失败不影响业务。
private val executor = Executors.newSingleThreadExecutor()
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
// Timber 在 prepareLog 阶段已把 Throwable 的堆栈拼进 message,此处只需落 message,避免重复。
executor.execute {
try {
val dir = logDir(context)
if (!dir.exists() && !dir.mkdirs()) return@execute
cleanupOldLogs(dir)
val file = resolveLogFile(dir)
FileOutputStream(file, true).use { fos ->
fos.write(buildLine(priority, tag, message).toByteArray(Charsets.UTF_8))
}
} catch (_: Exception) {
// 日志写失败静默,不阻断业务
}
}
}
private fun buildLine(priority: Int, tag: String?, message: String): String {
val time = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()).format(Date())
val level = when (priority) {
Log.VERBOSE -> "V"
Log.DEBUG -> "D"
Log.INFO -> "I"
Log.WARN -> "W"
Log.ERROR -> "E"
Log.ASSERT -> "A"
else -> "?"
}
val safeTag = tag ?: "Timber"
return "$time $level/$safeTag: $message\n"
}
/** 当前日志文件:不存在或未超限直接使用,超限则追加序号切分 */
private fun resolveLogFile(dir: File): File {
val day = SimpleDateFormat("yyyyMMdd", Locale.getDefault()).format(Date())
val base = File(dir, "log-$day.txt")
if (!base.exists() || base.length() < MAX_FILE_SIZE) return base
var index = 1
while (true) {
val candidate = File(dir, "log-$day-$index.txt")
if (!candidate.exists() || candidate.length() < MAX_FILE_SIZE) return candidate
index++
}
}
/** 清理超过保留天数的旧日志 */
private fun cleanupOldLogs(dir: File) {
val now = System.currentTimeMillis()
val maxAgeMillis = MAX_AGE_DAYS * 24 * 60 * 60 * 1000L
dir.listFiles()?.forEach { file ->
if (file.isFile && file.lastModified() < now - maxAgeMillis) file.delete()
}
}
}
@@ -0,0 +1,163 @@
package com.sw.platecabinet.utils
import android.content.ContentUris
import android.content.Context
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import timber.log.Timber
import java.io.File
import kotlin.io.copyTo
import kotlin.io.outputStream
import kotlin.io.use
import kotlin.text.equals
object FileUtils {
/**
* 从Uri获取File
* example: file:///data/user/0/com.sw.inbound/cache/IMG_17515262353556856678814444882273.jpg
*/
private fun getFileFromUri(context: Context, uri: Uri): File? {
Timber.d("getFileFromUri uri = ${uri.scheme}")
return when (uri.scheme) {
"file" -> File(uri.path ?: return null)
"content" -> {
try {
val inputStream = context.contentResolver.openInputStream(uri) ?: return null
val cacheDir = context.cacheDir
val file = File.createTempFile(
"upload_${System.currentTimeMillis()}",
".jpg",
cacheDir
)
file.outputStream().use { output ->
inputStream.copyTo(output)
}
file
} catch (e: Exception) {
Timber.e(e)
null
}
}
else -> null
}
}
/**
* 通过uri生成http请求体
*/
fun genRequestPart(context: Context, imageUri: Uri): MultipartBody.Part? {
Timber.d("genRequestPart imageUri = $imageUri")
// 1. 从Uri获取文件
val file = getFileFromUri(context, imageUri)
return genRequestPart(file)
}
fun genRequestPart(file: File?): MultipartBody.Part? {
if (file == null) {
Timber.e("getFileFromUri file is null")
return null
}
// 2. 创建请求体
val requestFile = file
.asRequestBody("application/octet-stream".toMediaTypeOrNull())
val imagePart = MultipartBody.Part.createFormData(
"file",
file.name,
requestFile
)
return imagePart
}
/**
* 通过Uri删除文件
* @param context 上下文
* @param uri 文件Uri
* @return Boolean 是否删除成功
*/
fun deleteFileWithUri(context: Context, uri: Uri): Boolean {
Timber.d("deleteFileWithUri uri = ${uri.scheme}")
return when {
// 1. 处理 content:// 类型的Uri (MediaStore)
uri.scheme.equals("content", ignoreCase = true) -> {
deleteContentUriFile(context, uri)
}
// 2. 处理 file:// 类型的Uri
uri.scheme.equals("file", ignoreCase = true) -> {
deleteFileUriFile(uri)
}
// 3. 其他情况尝试直接解析路径
else -> {
deleteFileFromPath(uri.path ?: return false)
}
}
}
// 删除Content Uri文件
private fun deleteContentUriFile(context: Context, uri: Uri): Boolean {
Timber.d("deleteContentUriFile uri = ${uri.scheme}")
return try {
context.contentResolver.delete(uri, null, null) > 0
} catch (e: SecurityException) {
// Android 10+需要特殊处理
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
deleteMediaStoreFile(context, uri)
} else {
false
}
} catch (e: Exception) {
Timber.e(e)
false
}
}
// Android 10+删除MediaStore文件
@RequiresApi(Build.VERSION_CODES.Q)
private fun deleteMediaStoreFile(context: Context, uri: Uri): Boolean {
Timber.d("deleteMediaStoreFile uri = ${uri.scheme}")
val contentResolver = context.contentResolver
val projection = arrayOf(MediaStore.MediaColumns._ID)
return try {
contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val id =
cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
val contentUri = ContentUris.withAppendedId(uri, id)
contentResolver.delete(contentUri, null, null) > 0
} else {
false
}
} ?: false
} catch (e: Exception) {
Timber.e(e)
false
}
}
// 删除File Uri文件
private fun deleteFileUriFile(uri: Uri): Boolean {
Timber.d("deleteFileUriFile uri = $uri")
return try {
File(uri.path ?: return false).delete()
} catch (e: Exception) {
Timber.e(e)
false
}
}
// 直接通过路径删除文件
private fun deleteFileFromPath(path: String): Boolean {
Timber.d("deleteFileFromPath path = $path")
return try {
File(path).delete()
} catch (e: Exception) {
Timber.e(e)
false
}
}
}
@@ -0,0 +1,36 @@
package com.sw.platecabinet.utils
import android.content.Context
import java.io.File
/**
* 日志文件管理:列出 / 读尾部 / 清理(供运维页与 OpsViewModel 使用)。
*/
object LogFileManager {
/** 列出运行时日志文件(按修改时间倒序) */
fun getLogFiles(context: Context): List<File> {
val dir = FileLoggingTree.logDir(context)
if (!dir.exists() || !dir.isDirectory) return emptyList()
val files = dir.listFiles { f -> f.isFile && f.name.endsWith(".txt") } ?: return emptyList()
return files.sortedByDescending { it.lastModified() }
}
/** 读取文件末尾 maxLines 行(只保留末尾,避免大文件一次性载入内存) */
fun readTail(file: File, maxLines: Int = 200): String {
if (!file.exists()) return "文件不存在"
val tail = ArrayDeque<String>()
file.bufferedReader(Charsets.UTF_8).useLines { seq ->
seq.forEach { line ->
if (tail.size >= maxLines) tail.removeFirst()
tail.addLast(line)
}
}
return tail.joinToString("\n")
}
/** 清理所有运行时日志文件 */
fun clearLogs(context: Context) {
getLogFiles(context).forEach { it.delete() }
}
}
@@ -0,0 +1,50 @@
package com.sw.platecabinet.utils
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import com.sw.platecabinet.ENVIRONMENTS
import java.net.Inet4Address
import java.net.InetAddress
import java.net.NetworkInterface
/**
* 网络状态只读提供器:连通性、本机 IP、业务环境名。
*/
object NetStatusProvider {
/** 连通性摘要(离线/WiFi/蜂窝/以太网/未知) */
fun connectivitySummary(context: Context): String {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
?: return "未知"
val network = cm.activeNetwork ?: return "离线"
val caps = cm.getNetworkCapabilities(network) ?: return "离线"
return when {
!caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) -> "离线(无外网)"
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "以太网"
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "WiFi"
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "蜂窝"
else -> "已连接"
}
}
/** 本机 IPv4 地址(取首个非回环地址,无则返回"未知" */
fun localIpv4(): String {
return try {
val addresses = mutableListOf<InetAddress>()
val interfaces = NetworkInterface.getNetworkInterfaces() ?: return "未知"
for (nif in interfaces) {
val addrs = nif.inetAddresses ?: continue
for (addr in addrs) addresses.add(addr)
}
addresses.firstOrNull { it is Inet4Address && !it.isLoopbackAddress }?.hostAddress ?: "未知"
} catch (e: Exception) {
"未知"
}
}
/** 由 baseUrl 反查预设环境名,未匹配返回"自定义" */
fun envName(baseUrl: String): String {
return ENVIRONMENTS.firstOrNull { it.url == baseUrl }?.name ?: "自定义"
}
}
@@ -1,11 +1,13 @@
package com.sw.platecabinet.utils;
import com.sw.inbound.utils.SPUtil;
import com.sw.platecabinet.GlobalKey;
import com.sw.platecabinet.MyApp;
public class SpTool {
public static final String LAST_FACE_TIMESTAMP = "faceTimestamp";
public static final String IS_FIRST_GET_FACE = "isFirstGetFace";
public static long getLastFaceTimestamp() {
return SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").get(LAST_FACE_TIMESTAMP, 0L);
@@ -15,6 +17,37 @@ public class SpTool {
SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").put(LAST_FACE_TIMESTAMP, timestamp);
}
/**
* 是否首次拉取人脸缓存(Kotlin 侧以 SpTool.firstGetFace 属性语法访问)
*/
public static boolean getFirstGetFace() {
return SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").get(IS_FIRST_GET_FACE, true);
}
public static void setFirstGetFace(boolean value) {
SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").put(IS_FIRST_GET_FACE, value);
}
/**
* 业务服务器 BaseUrlKotlin 侧以 SpTool.baseUrl 属性语法访问)
*/
public static String getBaseUrl() {
return SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").get(GlobalKey.KEY_BASE_URL, "");
}
public static void setBaseUrl(String value) {
SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").put(GlobalKey.KEY_BASE_URL, value);
}
/**
* 运维面板密码(默认 1234,运维可在面板内修改)
*/
public static String getOpsPin() {
return SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").get("opsPin", "1234");
}
public static void setOpsPin(String value) {
SPUtil.Companion.getInstance(MyApp.getContext(), "default_sp").put("opsPin", value);
}
}
@@ -0,0 +1,116 @@
package com.sw.platecabinet.utils
import android.content.Context
import android.os.Build
import android.os.storage.StorageManager
import java.io.File
/**
* U 盘定位:工控机现场无微信等分享应用,诊断包需直接写入 U 盘。
*
* 关键教训:仅凭「vfat/exfat/ntfs」判断会误判——部分瑞芯微固件把 /oempriv 等
* OEM 私有分区也格式化成 vfat。因此必须「路径 + 文件系统」双重校验:
* 只有挂载点落在 /storage/ 下、或路径明显含 usb/udisk/sdcard 关键字,才算 U 盘;
* 根目录的 /oem /odm /vendor /system 等系统分区一律排除。
*/
object UsbStorageHelper {
fun findUsbDir(context: Context): File? {
// 1. /storage 下卷标目录(XXXX-XXXX),Android 标准可移动存储位置
storageVolumeDir()?.let { return it }
// 2. /proc/mounts 里"明显是外接存储"的可移动介质挂载点
procMountsDir()?.let { return it }
// 3. 硬编码常见挂载点兜底(路径本身含 usb/udisk/sdcard,无需额外校验路径)
commonMountPoints().forEach { if (isWritableDir(it) && isRemovableFs(it)) return it }
// 4. StorageManager removable 卷(路径 + fsType 双重校验)
api30Volume(context)?.let {
if (isWritableDir(it) && isUsbMountPoint(it.absolutePath) && isRemovableFs(it)) return it
}
return null
}
private fun isWritableDir(dir: File): Boolean = dir.exists() && dir.isDirectory && dir.canWrite()
/** 挂载点是否"明显是外接存储":排除根目录系统分区 */
private fun isUsbMountPoint(path: String): Boolean {
val lower = path.lowercase()
if (lower.startsWith("/oem") || lower.startsWith("/odm") ||
lower.startsWith("/vendor") || lower.startsWith("/system") ||
lower.startsWith("/product") || lower.startsWith("/system_ext") ||
lower == "/data" || lower == "/cache" || lower == "/metadata" ||
lower == "/persist" || lower == "/mnt/vendor"
) return false
return lower.startsWith("/storage/") ||
lower.startsWith("/mnt/media_rw/") ||
lower.contains("usb") || lower.contains("udisk") ||
lower.contains("sdcard") || lower.contains("external_sd")
}
/** 判断目录在 /proc/mounts 中的文件系统是否为可移动介质类型 */
private fun isRemovableFs(dir: File): Boolean {
val path = dir.absolutePath
return try {
File("/proc/mounts").readLines().any { line ->
val parts = line.split(" ")
parts.size >= 3 &&
parts[1].replace("\\040", " ") == path &&
isRemovableFsType(parts[2])
}
} catch (e: Exception) {
false
}
}
private fun isRemovableFsType(fsType: String): Boolean =
fsType.contains("vfat") || fsType.contains("exfat") ||
fsType.contains("ntfs") || fsType.contains("fuseblk")
/** API 30+ 通过 StorageManager 拿可移动卷目录(不可靠,需路径 + fsType 双重佐证) */
private fun api30Volume(context: Context): File? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
return try {
val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager ?: return null
sm.storageVolumes.firstOrNull { it.isRemovable }?.directory
} catch (e: Exception) {
null
}
}
/** /storage 下卷标目录(U 盘卷形如 /storage/ABCD-1234 */
private fun storageVolumeDir(): File? {
return try {
File("/storage").listFiles()
?.filter { it.isDirectory && it.name != "emulated" && it.name != "self" && it.canWrite() }
?.firstOrNull { isRemovableFs(it) }
} catch (e: Exception) {
null
}
}
/** 常见挂载点(路径本身即含 usb/udisk/sdcard 关键字) */
private fun commonMountPoints(): List<File> = listOf(
File("/mnt/usb_storage"),
File("/mnt/usb"),
File("/mnt/udisk"),
File("/storage/usb"),
File("/mnt/external_sd"),
File("/mnt/sdcard2")
)
/** 读 /proc/mounts 找"明显是外接存储"的可移动介质挂载点 */
private fun procMountsDir(): File? {
return try {
File("/proc/mounts").readLines()
.mapNotNull { line ->
val parts = line.split(" ")
if (parts.size < 3) return@mapNotNull null
val fsType = parts[2]
val mountPoint = parts[1].replace("\\040", " ")
if (isRemovableFsType(fsType) && isUsbMountPoint(mountPoint)) File(mountPoint) else null
}
.firstOrNull { isWritableDir(it) }
} catch (e: Exception) {
null
}
}
}
@@ -3,7 +3,6 @@ package com.sw.platecabinet.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sw.plate.utils.ToastUtils
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.model.response.ApiResponse
import com.sw.platecabinet.model.response.EquipmentInfo
import com.sw.platecabinet.network.ApiClient
@@ -0,0 +1,328 @@
package com.sw.platecabinet.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.arcsoft.face.ErrorInfo
import com.sw.inbound.utils.SPUtil
import com.sw.plate.App
import com.sw.plate.utils.Base64
import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.arcface.FaceApi
import com.sw.plate.utils.arcface.facedb.FaceDatabase
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.GlobalKey
import com.sw.platecabinet.model.DeviceConfigV2
import com.sw.platecabinet.model.response.UserFaceModel
import com.sw.platecabinet.model.response.UserFaceModelV2
import com.sw.platecabinet.mqtt.FaceSyncLock
import com.sw.platecabinet.network.ApiClient
import com.sw.platecabinet.repository.RemoteRepositoryV2
import com.sw.platecabinet.utils.SpTool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
/**
* 用户viewmodel
*/
class NetViewModelV2 : ViewModel() {
companion object {
const val PAGE_SIZE = 500
}
private val faceApi: FaceApi = FaceApi()
private val repository = RemoteRepositoryV2(ApiClient.apiServiceV2)
//fun getUserFace(
// pageNo: Int,
// pageSize: Int,
// callback: (Boolean, List<UserFaceModelV2>) -> Unit
//) {
// viewModelScope.launch {
// val response = repository.getUserFaceCache(pageNum = pageNo, pageSize = pageSize)
// if (response.isSuccess()) callback(true, response.data ?: emptyList())
// else callback(false, emptyList())
// }
//}
/**
* 获取人脸数据
*/
fun getUserFaceCache(
pageNo: Int = 1,
pageSize: Int = PAGE_SIZE,
callback: (Boolean, String?) -> Unit = { _, _ -> }
) {
var currentPageNo = pageNo
viewModelScope.launch {
val response = repository.getUserFaceCache(pageNo, pageSize)
if (response.isSuccess().not()) {
callback(false, response.msg)
return@launch
}
// 获取成功一次后缓存状态
SPUtil.getInstance().put(GlobalKey.KEY_FIRST_RUN, true)
val list = response.data ?: emptyList()
if (pageNo == 1 && list.isEmpty()) {
callback(false, "暂无数据")
return@launch
}
val faceEntityList = list.mapNotNull { face ->
val featureBase64 = face.resolveFeatureStr()
if (featureBase64.isNullOrBlank()) null
else {
val featureData = base64ToByteArray(featureBase64)
if (featureData == null) null
else face.toFaceEntity()
}
}
withContext(Dispatchers.Default) {
if (currentPageNo == 1) {
//第一页数据删除本地数据库,同时重置时间戳
faceApi.clearFaceData()
faceTimestamp = 0L
}
faceApi.updateFaceData(pageNo, faceEntityList)
if (list.size >= pageSize) {
// 取当前页最大时间戳,与已累积的比较取最大值
val pageMaxTimestamp = maxFaceTimestamp(list)
if (pageMaxTimestamp > faceTimestamp) {
faceTimestamp = pageMaxTimestamp
}
currentPageNo++
getUserFaceCache(
pageNo = currentPageNo, callback = callback
)
return@withContext
}
if (list.isNotEmpty()) {
// 最后一页:取当前页最大时间戳,与已累积的比较取最大值
val pageMaxTimestamp = maxFaceTimestamp(list)
if (pageMaxTimestamp > faceTimestamp) {
faceTimestamp = pageMaxTimestamp
}
}
SpTool.setLastFaceTimestamp(faceTimestamp)
callback(true, "查询完成")
}
}
}
private var faceTimestamp = 0L
/**
* 激活人脸识别引擎
*/
fun activeEngine() {
Timber.d("activeEngine")
faceApi.activeEngine(
App.getContext(),
GlobalData.appId,
GlobalData.sdkKey,
GlobalData.activeKey,
object : FaceApi.ActiveCallback {
override fun onSuccess(activeCode: Int) {
Timber.d("activeEngine activeCode = $activeCode=="+GlobalData.activeKey)
// ThreadUtils.launch {
viewModelScope.launch(Dispatchers.Main) {
when (activeCode) {
ErrorInfo.MOK -> {
ToastUtils.showToast("激活引擎成功")
}
ErrorInfo.MERR_ASF_ALREADY_ACTIVATED -> {
ToastUtils.showToast("引擎已激活,无需再次激活")
}
else -> {
ToastUtils.showToast("激活引擎失败($activeCode)")
}
}
}
}
override fun onFail(e: Exception?) {
viewModelScope.launch(Dispatchers.Main) {
ToastUtils.showToast("激活引擎异常,${e?.message}")
}
}
})
}
fun getDeviceConfig(onSuccess: (DeviceConfigV2?) -> Unit, onFailure: (String?) -> Unit) {
// Timber.tag(TAG).d("getDeviceConfig")
viewModelScope.launch {
val response = repository.getDeviceConfig()
if (response.isSuccess()) {
onSuccess(response.data)
} else {
onFailure(response.msg)
}
}
}
/**
* 增量获取人脸数据(支持分页递归,取所有页中的最大时间戳)
*/
fun getFaceIncrementList(
pageNo: Int = 1,
pageSize: Int = PAGE_SIZE,
timestamp: Long,
refreshFaceList: () -> Unit
) {
var currentPageNo = pageNo
viewModelScope.launch {
val response = repository.getFaceIncrementList(
pageNum = currentPageNo.toLong(),
pageSize = pageSize.toLong(),
timestamp = timestamp
)
if (response.isSuccess().not()) {
return@launch
}
withContext(Dispatchers.IO) {
val list = response.data ?: emptyList()
// 与 MQTT 实时更新串行化写库,避免同一 userFaceId 重复入库
FaceSyncLock.mutex.withLock { updateFaceData(list) }
// 取当前页最大时间戳,与已累积的比较取最大值
val pageMaxTimestamp = maxFaceTimestamp(list)
if (pageMaxTimestamp > faceTimestamp) {
faceTimestamp = pageMaxTimestamp
}
if (list.size >= pageSize) {
// 还有下一页,继续递归拉取
currentPageNo++
getFaceIncrementList(
pageNo = currentPageNo,
pageSize = pageSize,
timestamp = timestamp,
refreshFaceList = refreshFaceList
)
return@withContext
}
// 所有页拉取完毕,保存最终时间戳(只升不降)
val storedTimestamp = SpTool.getLastFaceTimestamp()
if (faceTimestamp > storedTimestamp) {
SpTool.setLastFaceTimestamp(faceTimestamp)
}
try {
refreshFaceList()
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
private fun updateFaceData(list: List<UserFaceModelV2>) {
try {
list.forEach { model ->
if (model.faceDeleted == true) {
//删除数据
// 仅删除 userFaceId 对应的单条人脸记录,避免误删该用户名下所有特征
val userFaceId = model.userFaceId
if (!userFaceId.isNullOrEmpty()) {
faceApi.deleteByUserFaceId(userFaceId)
}
} else {
// 优先按 userFaceId 精确判重,避免特征字段不一致导致重复入库
val userFaceId = model.userFaceId
if (!userFaceId.isNullOrEmpty() && faceApi.queryByUserFaceId(userFaceId) != null) {
// 本地已存在该 userFaceId,跳过插入
return@forEach
}
// userFaceId 为空时,回退到特征比较去重(修正字段来源,保证与入库一致)
val featureStr = model.resolveFeatureStr()
val existList = faceApi.queryAllByUserName(model.userId)
val alreadyExists = existList.any { entity ->
featureStr != null && Base64.encode(entity.featureData) == featureStr
}
if (!alreadyExists) {
faceApi.insert(model.toFaceEntity())
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 添加人脸数据
*/
fun addUserFace(faceData: String, imageFile: File, block: (Boolean, UserFaceModelV2?) -> Unit) {
viewModelScope.launch {
try {
val imageResp = repository.uploadImage(imageFile)
val imageUrl = imageResp.data ?: ""
if (imageUrl.isBlank()) {
block(false, null)
return@launch
}
val faceResp = repository.addUserFace(faceData, imageUrl)
if (faceResp.isSuccess().not()) {
block(false, null)
return@launch
}
block(true, faceResp.data)
} catch (e: Exception) {
// 网络或解析异常时同样回调失败,避免 block 永不触发导致上层无反馈
e.printStackTrace()
block(false, null)
}
}
}
/**
* 取餐盘上报(P-09a):记录用户开始就餐时刻,作为后续就餐记录(P-09)的强前置。
* 失败仅回调通知,不阻断吐盘等业务主流程。
*
* @param userId 用户 id(餐盘号 = userId
* @param callback 上报结果回调:Boolean 是否成功,String? 成功为 recordNo、失败为错误信息
*/
fun platePickup(
userId: Long,
callback: (Boolean, String?) -> Unit = { _, _ -> }
) {
viewModelScope.launch {
try {
val response = repository.platePickup(userId)
if (response.isSuccess()) {
callback(true, response.data)
} else {
callback(false, response.msg)
}
} catch (e: Exception) {
e.printStackTrace()
callback(false, e.message)
}
}
}
private fun base64ToByteArray(base64: String?): ByteArray? {
base64 ?: return null
return try {
Base64.decode(base64)
} catch (e: Exception) {
null
}
}
/**
* 从人脸数据列表中取出最大的 faceUpdateTimestamp(跳过 null 值)
*/
private fun maxFaceTimestamp(list: List<UserFaceModelV2>): Long {
return list.mapNotNull { it.faceUpdateTimestamp }.maxOrNull() ?: 0L
}
}
@@ -0,0 +1,151 @@
package com.sw.platecabinet.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sw.plate.utils.arcface.FaceApi
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.mqtt.MqttManager
import com.sw.platecabinet.mqtt.MqttState
import com.sw.platecabinet.utils.LogFileManager
import com.sw.platecabinet.utils.NetStatusProvider
import com.sw.platecabinet.utils.SpTool
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
/**
* 运维面板 ViewModel:聚合 MQTT / 网络 / 人脸 / 日志 状态与动作。
*/
class OpsViewModel : ViewModel() {
private val faceApi = FaceApi()
// ---- MQTT(直接桥接 MqttManager 单例的 StateFlow ----
val mqttState: StateFlow<MqttState> = MqttManager.state
val mqttLastConnectedAt: StateFlow<Long?> = MqttManager.lastConnectedAt
val mqttLastLostAt: StateFlow<Long?> = MqttManager.lastLostAt
val mqttLastError: StateFlow<String?> = MqttManager.lastError
val mqttLastMessageAt: StateFlow<Long?> = MqttManager.lastMessageArrivedAt
val mqttConnectCount: StateFlow<Int> = MqttManager.connectCount
val mqttDisconnectCount: StateFlow<Int> = MqttManager.disconnectCount
// ---- 人脸统计 ----
private val _faceCount = MutableStateFlow(0)
val faceCount: StateFlow<Int> = _faceCount.asStateFlow()
private val _memberCount = MutableStateFlow(0)
val memberCount: StateFlow<Int> = _memberCount.asStateFlow()
private val _nonMemberCount = MutableStateFlow(0)
val nonMemberCount: StateFlow<Int> = _nonMemberCount.asStateFlow()
private val _maxUpdateTs = MutableStateFlow<Long?>(null)
val maxUpdateTs: StateFlow<Long?> = _maxUpdateTs.asStateFlow()
private val _recentFaces = MutableStateFlow<List<FaceEntity>>(emptyList())
val recentFaces: StateFlow<List<FaceEntity>> = _recentFaces.asStateFlow()
// ---- 人脸搜索 ----
private val _searchResult = MutableStateFlow<List<FaceEntity>>(emptyList())
val searchResult: StateFlow<List<FaceEntity>> = _searchResult.asStateFlow()
// ---- 服务端连通自检 ----
private val _serverCheck = MutableStateFlow("未检测")
val serverCheck: StateFlow<String> = _serverCheck.asStateFlow()
// ---- 日志文件 ----
private val _logFiles = MutableStateFlow<List<File>>(emptyList())
val logFiles: StateFlow<List<File>> = _logFiles.asStateFlow()
// ---- 只读快照 ----
val appBaseUrl: String get() = GlobalData.appBaseUrl
val envName: String get() = NetStatusProvider.envName(GlobalData.appBaseUrl)
val lastFaceTimestamp: Long get() = SpTool.getLastFaceTimestamp()
val deviceId: String get() = GlobalData.deviceId
val equipmentCode: String get() = GlobalData.globalEquipmentCode
val appVersion: String get() = GlobalData.appVersion
val mqttBrokerUrl: String? get() = MqttManager.brokerUrl
val mqttClientId: String? get() = MqttManager.clientId
/** 刷新本地人脸统计与最近更新列表(FaceDao 为同步调用,放 IO 线程) */
fun refreshFaceStats() {
viewModelScope.launch(Dispatchers.IO) {
try {
_faceCount.value = faceApi.queryFaceCount()
_memberCount.value = faceApi.queryFaceCountByMember(true)
_nonMemberCount.value = faceApi.queryFaceCountByMember(false)
_maxUpdateTs.value = faceApi.queryMaxFaceUpdateTimestamp()
_recentFaces.value = faceApi.queryRecentUpdatedFaces(50)
} catch (e: Exception) {
// 数据库读取异常仅打印,不影响面板其余区块
_recentFaces.value = emptyList()
}
}
}
/** 按 userId 精确搜索人脸(库内无姓名字段,只能按 userId 搜索) */
fun searchFace(userId: String) {
viewModelScope.launch(Dispatchers.IO) {
_searchResult.value = faceApi.queryByUserId(userId.trim(), 50)
}
}
/**
* 服务端连通自检:GET 真实设备配置接口(而非 baseUrl 根路径,避免 404 也判可达)。
* 任何 HTTP 响应都视为"服务进程存活"(能区分"网络断/服务没起" vs "服务在跑")。
*/
fun checkServer() {
viewModelScope.launch(Dispatchers.IO) {
val baseUrl = GlobalData.appBaseUrl.ifBlank {
_serverCheck.value = "未配置业务地址"
return@launch
}
_serverCheck.value = "检测中…"
val url = "${baseUrl.trimEnd('/')}/nutrition/neglect/pickup/device/config"
val client = OkHttpClient.Builder()
.connectTimeout(3, TimeUnit.SECONDS)
.readTimeout(3, TimeUnit.SECONDS)
.build()
val request = Request.Builder().url(url).get().build()
val start = System.currentTimeMillis()
try {
client.newCall(request).execute().use { resp ->
val cost = System.currentTimeMillis() - start
_serverCheck.value = "可达 HTTP ${resp.code}${cost}ms"
}
} catch (e: Exception) {
val cost = System.currentTimeMillis() - start
_serverCheck.value = "不可达(${cost}ms):${e.message}"
}
}
}
/** 刷新日志文件列表 */
fun refreshLogFiles(context: Context) {
viewModelScope.launch(Dispatchers.IO) {
_logFiles.value = LogFileManager.getLogFiles(context)
}
}
/** 手动重连 MQTT */
fun reconnectMqtt() {
MqttManager.reconnect()
}
/** 时间戳格式化(毫秒 → yyyy-MM-dd HH:mm:ss,空/0 显示 —) */
fun formatTs(ts: Long?): String {
if (ts == null || ts <= 0L) return ""
return SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(ts))
}
}
@@ -158,7 +158,7 @@ class SettingViewModel : BaseViewModel() {
// }
// }
fun unbindPlate(id: Long?, block:(Pair<Boolean?, ErrorInfo>)-> Unit) {
fun unbindPlate(id: String?, block:(Pair<Boolean?, ErrorInfo>)-> Unit) {
block(null to ErrorInfo())
launchWithLoading {
val response = repository.plateUnbind(id)
@@ -12,7 +12,6 @@ import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.GlobalData.globalEquipmentCode
import com.sw.platecabinet.GlobalKey
import com.sw.platecabinet.model.DeviceConfig
import com.sw.platecabinet.model.request.LoginParam
import com.sw.platecabinet.model.response.EquipmentUserInfo
import com.sw.platecabinet.model.response.UserFaceModel
import com.sw.platecabinet.utils.SpTool
@@ -92,69 +91,30 @@ class UserViewModel : BaseViewModel() {
private var faceTimestamp = 0L
/**
* 激活人脸识别引擎
*/
fun activeEngine() {
Timber.d("activeEngine")
faceApi.activeEngine(
App.getContext(),
GlobalData.appId,
GlobalData.sdkKey,
GlobalData.activeKey,
object : FaceApi.ActiveCallback {
override fun onSuccess(activeCode: Int) {
Timber.d("activeEngine activeCode = $activeCode")
// ThreadUtils.launch {
viewModelScope.launch(Dispatchers.Main) {
when (activeCode) {
ErrorInfo.MOK -> {
ToastUtils.showToast("激活引擎成功")
}
ErrorInfo.MERR_ASF_ALREADY_ACTIVATED -> {
// ToastUtils.showToast("引擎已激活,无需再次激活")
}
else -> {
ToastUtils.showToast("激活引擎失败($activeCode)")
}
}
}
}
override fun onFail(e: Exception?) {
viewModelScope.launch(Dispatchers.Main) {
ToastUtils.showToast("激活引擎异常,${e?.message}")
}
}
})
}
/**
* 校验码登录
*/
fun loginWithPwd(loginParam: LoginParam) {
launchWithLoading {
val response = repository.equipmentBoxLogin(loginParam)
if (parseResponse(response)) {
_currentUserInfo.value = response.data
}
}
}
/**
* 通过用户id获取用户信息
*
* 无论成功或失败(如「未找到用户绑定信息」)都会回调 action,
* 以便调用方统一收尾(例如关闭 loading 弹窗)。
* 成功时回调绑定的用户信息,失败时回调 null。
*
* @param silent true 表示失败时不弹错误 toast。绑定页点会员检查是否已绑定
* 时,「未找到绑定信息」属于正常态,应静默处理。
*/
fun getUserInfoById(memberId: String?, action:(EquipmentUserInfo?)->Unit={}) {
fun getUserInfoById(memberId: String?, silent: Boolean = false, action:(EquipmentUserInfo?)->Unit={}) {
_currentUserInfo.value = null
launchWithLoading {
val loginParam = LoginParam(faceId = memberId)
val response = repository.equipmentBoxLogin(loginParam)
if (parseResponse(response)) {
val response = repository.getMemberRefPlateByUserId(userId = memberId)
if (response.isSuccess()) {
_currentUserInfo.value = response.data
action(response.data)
} else {
if (!silent) {
Timber.d("msg = ${response.msg}, code = ${response.code}")
ToastUtils.showToast("${response.msg}(${response.code})")
}
action(null)
}
}
}
+18 -30
View File
@@ -12,52 +12,40 @@
android:id="@+id/includeHeader"
layout="@layout/item_title_time" />
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="2"/>
<!-- 内容区:占满头部以下剩余空间并居中显示 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="0dp"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:layout_width="wrap_content"
android:layout_height="142dp"
android:src="@drawable/ic_init_text"
android:adjustViewBounds="true"/>
android:adjustViewBounds="true"
android:importantForAccessibility="no"
android:src="@drawable/ic_init_text" />
<ImageView
android:layout_width="wrap_content"
android:layout_height="345dp"
android:layout_marginTop="90dp"
android:src="@drawable/ic_init_img"
android:adjustViewBounds="true" />
android:adjustViewBounds="true"
android:importantForAccessibility="no"
android:src="@drawable/ic_init_img" />
<!-- <ImageView-->
<!-- android:layout_width="wrap_content"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:layout_marginTop="20dp"-->
<!-- android:src="@drawable/ic_init_press" />-->
<TextView android:id="@+id/takeButton"
<TextView
android:id="@+id/takeButton"
android:layout_width="wrap_content"
android:layout_height="60dp"
android:text="点击取盘"
android:textColor="#FFCC99"
android:textSize="24sp"
android:paddingHorizontal="45dp"
android:gravity="center"
android:background="@drawable/btn_outline"
tools:ignore="HardcodedText" />
android:gravity="center"
android:paddingStart="45dp"
android:paddingEnd="45dp"
android:text="@string/init_take_plate"
android:textColor="@color/init_button_text"
android:textSize="24sp" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"/>
</LinearLayout>
</LinearLayout>
@@ -1,122 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg"
android:orientation="vertical">
<include
android:id="@+id/includeHeader"
layout="@layout/item_title_time" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:paddingStart="60dp"
android:paddingEnd="60dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="手机后四位"
android:textColor="#FFCC99"
android:textSize="26sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_marginTop="23dp"
android:background="@drawable/shape_pwd_bg"
android:gravity="center_vertical">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginEnd="55dp"
android:src="@drawable/ic_phone" />
<EditText
android:id="@+id/et_phone"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginEnd="20dp"
android:background="@android:color/transparent"
android:hint="输入手机后四位"
android:imeOptions="actionNext"
android:inputType="number"
android:maxLength="4"
android:maxLines="1"
android:textColor="#F3D2BD"
android:textColorHint="#7B6D6A"
android:textSize="26sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="25dp"
android:text="校验码"
android:textColor="#FFCC99"
android:textSize="26sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_marginTop="23dp"
android:background="@drawable/shape_pwd_bg"
android:gravity="center_vertical">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginEnd="55dp"
android:src="@drawable/ic_pwd" />
<EditText
android:id="@+id/et_pwd"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginEnd="20dp"
android:background="@android:color/transparent"
android:hint="输入校验码"
android:inputType="numberPassword"
android:maxLines="1"
android:textColor="#F3D2BD"
android:textColorHint="#7B6D6A"
android:imeOptions="actionDone"
android:textSize="26sp"
android:textStyle="bold" />
</LinearLayout>
<android.widget.Button
android:id="@+id/btnLogin"
android:layout_width="match_parent"
android:layout_height="80dp"
android:layout_marginTop="46dp"
android:background="@drawable/shape_btn_bg"
android:text="登录"
android:textColor="#11111C"
android:textSize="30sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvFaceRec"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="40dp"
android:padding="8dp"
android:text="人脸识别"
android:textColor="#FFCC99"
android:textSize="26sp"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
@@ -59,9 +59,9 @@
android:layout_gravity="center_horizontal"
android:layout_marginTop="129dp"
android:text="请正视屏幕"
android:textStyle="bold"
android:textColor="#FFCC99"
android:textSize="36sp" />
android:textSize="36sp"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/ll_to_pwd"
@@ -72,7 +72,8 @@
android:background="@drawable/btn_outline"
android:gravity="center"
android:paddingHorizontal="45dp"
android:paddingVertical="18dp">
android:paddingVertical="18dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
@@ -86,12 +87,12 @@
android:id="@+id/tv_to_init"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:text="30"
android:padding="12dp"
android:gravity="center"
android:layout_marginTop="185dp"
android:gravity="center"
android:padding="12dp"
android:textColor="#FFCC99"
android:textSize="36sp" />
android:textSize="36sp"
tools:text="30" />
</FrameLayout>
</LinearLayout>
+452
View File
@@ -0,0 +1,452 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@drawable/bg"
tools:context=".activity.OpsActivity">
<include
android:id="@+id/includeHeader"
layout="@layout/item_title_time" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fillViewport="true">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="24dp"
android:paddingTop="12dp"
android:paddingBottom="40dp">
<!-- 网络状态 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="网络状态"
android:textColor="#FFCC99"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvNetSummary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="以太网 | 本机IP: 192.168.1.100" />
<TextView
android:id="@+id/tvNetBaseUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="环境: 测试 | https://dev.yixiong-tech.com:8081" />
<TextView
android:id="@+id/tvDeviceInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="设备ID: xxx | 设备编号: 202507231144 | 版本: 1.1" />
<Button
android:id="@+id/btnCheckServer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="服务端连通自检"
android:textSize="14sp" />
<TextView
android:id="@+id/tvServerCheck"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="#FFCC99"
android:textSize="14sp"
tools:text="可达 HTTP 200123ms" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="12dp"
android:background="#333D4D" />
<!-- 设备健康 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="设备健康"
android:textColor="#FFCC99"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvDeviceHealth"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="运行时长: 12小时3分 | 内存: 可用 512MB/总 2048MB | 存储: 可用 8GB/总 16GB" />
<TextView
android:id="@+id/tvCameraInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="摄像头: 2 个 | 权限: 已授予" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="12dp"
android:background="#333D4D" />
<!-- 串口 / 柜锁 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="串口 / 柜锁"
android:textColor="#FFCC99"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvSerialInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="串口: /dev/ttyS2 @ 19200 | 状态: 未打开" />
<Button
android:id="@+id/btnInitSerial"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="初始化串口"
android:textSize="14sp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="12dp"
android:background="#333D4D" />
<!-- MQTT 连接 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="MQTT 连接"
android:textColor="#FFCC99"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvMqttState"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="状态: Connected" />
<TextView
android:id="@+id/tvMqttBroker"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="Broker: wss://dev.yixiong-tech.com:8089 | clientId: platecabinet-xxx" />
<TextView
android:id="@+id/tvMqttSubs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="订阅: yx/device/face/update-test (qos=1)" />
<TextView
android:id="@+id/tvMqttTimes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="连接: 2026-09-02 10:00:00 | 丢失: — | 错误: —" />
<Button
android:id="@+id/btnReconnectMqtt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="手动重连"
android:textSize="14sp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="12dp"
android:background="#333D4D" />
<!-- 人脸数据 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="人脸数据"
android:textColor="#FFCC99"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvFaceCount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="人脸总数: 0 | 会员: 0 | 临时: 0" />
<TextView
android:id="@+id/tvFaceWatermark"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="同步水位: 2026-09-02 10:00:00 | 库内最大更新: —" />
<Button
android:id="@+id/btnRefreshFace"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="刷新统计"
android:textSize="14sp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="12dp"
android:background="#333D4D" />
<!-- 最近更新人脸 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="最近更新人脸"
android:textColor="#FFCC99"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvRecentEmpty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="暂无数据"
android:textColor="#999999"
android:textSize="14sp"
android:visibility="gone"
tools:visibility="visible" />
<!-- 人脸搜索(按 userId -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<EditText
android:id="@+id/etSearch"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="按 userId 搜索"
android:inputType="text"
android:maxLines="1"
android:textColor="#E6E6E6"
android:textColorHint="#888888"
android:textSize="14sp" />
<Button
android:id="@+id/btnSearch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="搜索"
android:textSize="14sp" />
</LinearLayout>
<TextView
android:id="@+id/tvSearchResult"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="#E6E6E6"
android:textSize="13sp"
android:visibility="gone"
tools:visibility="visible"
tools:text="匹配 1 条: ufid=xxx uid=123 member=true 2026-09-02 10:00:00" />
<LinearLayout
android:id="@+id/llRecentList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="12dp"
android:background="#333D4D" />
<!-- 运行日志 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="运行日志"
android:textColor="#FFCC99"
android:textSize="20sp"
android:textStyle="bold" />
<TextView
android:id="@+id/tvLogFiles"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="运行日志: 0 个文件" />
<TextView
android:id="@+id/tvCrashInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#E6E6E6"
android:textSize="14sp"
tools:text="崩溃日志: 0 个文件" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical">
<Button
android:id="@+id/btnReadLog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="查看最新日志"
android:textSize="14sp" />
<Button
android:id="@+id/btnReadCrash"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="查看最新崩溃日志"
android:textSize="14sp" />
<Button
android:id="@+id/btnExport"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="导出诊断包"
android:textSize="14sp" />
<Button
android:id="@+id/btnClearLogs"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="清空日志"
android:textSize="14sp" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="12dp"
android:background="#333D4D" />
<!-- 干预操作 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="干预操作(需确认)"
android:textColor="#FFCC99"
android:textSize="20sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical">
<Button
android:id="@+id/btnFullSync"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="获取全量人脸"
android:textSize="14sp" />
<Button
android:id="@+id/btnTriggerSync"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="手动增量补拉"
android:textSize="14sp" />
<Button
android:id="@+id/btnClearFace"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="清空本地人脸库"
android:textSize="14sp" />
<Button
android:id="@+id/btnSwitchEnv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="切换环境(重启)"
android:textSize="14sp" />
<Button
android:id="@+id/btnChangePin"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="修改运维密码"
android:textSize="14sp" />
</LinearLayout>
</LinearLayout>
</ScrollView>
</LinearLayout>
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp"
android:background="@drawable/bg_dialog">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:text="选择业务环境"
android:textColor="@color/white"
android:textSize="20sp"
android:textStyle="bold" />
<RadioGroup
android:id="@+id/rgEnvironments"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:orientation="vertical" />
<TextView
android:id="@+id/tvConfirm"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:background="@drawable/bg_init_button"
android:gravity="center"
android:padding="10dp"
android:text="确定"
android:textColor="@color/white"
android:textSize="20sp" />
</LinearLayout>
+1
View File
@@ -15,4 +15,5 @@
<color name="tip_title_success">#ff02f1be</color>
<color name="tip_title_fail">#FFCC99</color>
<color name="tip_sub_title">#FFCC99</color>
<color name="init_button_text">#FFCC99</color>
</resources>
+2 -1
View File
@@ -1,5 +1,6 @@
<resources>
<string name="app_name">餐盘柜</string>
<string name="app_name">餐盘柜会员版</string>
<string name="init_take_plate">点击取盘</string>
<!-- TODO: Remove or change this placeholder text -->
<string name="hello_blank_fragment">Hello blank fragment</string>
@@ -0,0 +1,119 @@
# 业务 BaseUrl 切换 — 设计文档
- 日期:2026-09-01
- 状态:已确认
- 关联模块:网络层 / 设备初始化 / 全局配置
## 1. 背景与目标
SmartTakePlate(发盘机)当前业务服务地址由 `GlobalData.appBaseUrl` 决定,但该字段:
- 默认值为空串,全局无任何活跃代码赋值(仅剩注释)。
- 已具备一套"半成品"基础设施(`SpTool.getBaseUrl/setBaseUrl``GlobalKey.KEY_BASE_URL`
`LOCAL/TEST/PROD_BASE_URL` 常量),但从未接线。
目标:让现场运维人员能在**设备端**切换业务环境(本地 / 测试 / 生产),切换结果持久化,
重启后生效。
## 2. 需求
- **入口**:设备端隐藏入口,置于**登录前的初始化界面**(`DeviceInitActivity`)。
原因:若入口放在业务主界面,一旦配置的服务地址访问不到,业务界面因依赖网络请求成功才能
进入,将无法切回正确地址,形成死锁。
- **生效方式**:持久化到 SharedPreferences,重启 app 后生效(最简单可靠)。
- **选项来源**:仅预设环境列表(本地 / 测试 / 生产),不支持手输。
## 3. 现状分析
| 已存在 | 状态 |
|---|---|
| `GlobalData.appBaseUrl``var String = ""` | 默认空串,无活跃赋值 |
| `LOCAL_BASE_URL` / `TEST_BASE_URL` / `PROD_BASE_URL` | 已定义,不带尾斜杠 |
| `GlobalKey.KEY_BASE_URL = "baseUrlKey"` | 已定义 |
| `SpTool.getBaseUrl() / setBaseUrl()` | 已实现,无调用方 |
| `ApiClient.retrofit``by lazy``baseUrl(GlobalData.appBaseUrl)` | 用空串构建,切环境不重建 |
| `ApiService` / `ApiServiceV2` 全部方法 | 用 `@Url url = "${GlobalData.appBaseUrl}/..."` 默认参数,调用时求值 |
关键结论:
1. 所有请求地址由 `GlobalData.appBaseUrl` 在**每次调用时**经默认参数拼接决定,故切环境
只需改这一个变量(配合重启重建 `Retrofit`)。
2. `GlobalData.appBaseUrl=""` 会让 `Retrofit.Builder().baseUrl("")` 抛异常 —— 现有隐患。
## 4. 设计
### 4.1 组件划分与改动清单
| 文件 | 改动 |
|---|---|
| `GlobalData.kt` | 补充环境列表模型(名称 + URL),复用已有 `LOCAL/TEST/PROD_BASE_URL` |
| `MyApp.kt` | `initGlobalData()` 接线:读 `SpTool.getBaseUrl()`,兜底 `TEST_BASE_URL` |
| `DeviceInitActivity.kt` | 加隐藏连点手势 + 弹环境选择弹窗 |
| `ApiClient.kt` | 修复 Retrofit baseUrl 校验(补尾斜杠 / 占位),消除空串崩溃 |
| 新增 `EnvironmentSelectDialog`(或内联 Dialog) | 环境单选弹窗,高亮当前项 |
| `BaseActivity.kt`(复用) | 复用已有 `clearAllFace()` 清人脸库 |
### 4.2 关键设计决策
**决策 A — URL 规范化(消除现有隐患)**
- 约定 `GlobalData.appBaseUrl` **不带尾斜杠**,供 `@Url` 拼接得到形如
`https://dev.yixiong-tech.com:8081/terminal/...` 的完整地址。
- `Retrofit` 的 baseUrl 单独规范化:空则占位 `http://localhost/`,非空且无尾斜杠则补 `/`
因所有请求走 `@Url` 全路径,Retrofit baseUrl 不参与实际拼接,只需合法即可。
**决策 B — 切换时清理环境残留**
选中环境后执行:
1. `SpTool.setBaseUrl(url)` 持久化;
2. 清空本地人脸库(`FaceDatabase` / `clearAllFace()`);
3. 重置人脸时间戳 `lastFaceTimestamp = 0`
4. 重置 `firstGetFace = true`
原因:不同环境的人脸数据与时间戳不同,不清理会导致重启后增量同步对不上。
另:ArcSoft 激活参数(`appId` / `sdkKey` / `activeKey`)无需单独处理——`DeviceInitActivity`
每次启动都会调用 `getDeviceConfig()` 从新环境重新获取并覆盖,切环境后自动适配。
**决策 C — 隐藏手势**
`DeviceInitActivity` 连续点击屏幕(如 5 次)触发,不新增可见 UI 元素。该界面登录前
可达、不依赖网络,满足"配错地址也能切回"的要求。
## 5. 数据流
```mermaid
sequenceDiagram
participant User as 运维人员
participant App as MyApp/DeviceInit
participant Sp as SpTool(SharedPrefs)
participant Net as Retrofit/网络
App->>Sp: 启动时读 KEY_BASE_URL
alt 有值
Sp-->>App: 返回已存 URL
else 无值(首次)
Sp-->>App: 空 → 用 TEST_BASE_URL
end
App->>App: GlobalData.appBaseUrl = 结果
User->>App: 初始化界面连点5次
App->>App: 弹环境选择Dialog(高亮当前)
User->>App: 选择「生产」并确认
App->>Sp: setBaseUrl(PROD)
App->>App: 清人脸库 + 重置时间戳 + firstGetFace=true
App->>User: Toast「重启后生效」
User->>App: 重启 app
App->>Net: appBaseUrl 已指向生产 → 全量拉新环境人脸
```
## 6. 错误处理
- **baseUrl 空 / 非法**:兜底 `TEST_BASE_URL`,并保证 Retrofit baseUrl 合法(占位/补斜杠)。
- **清人脸库失败**:非致命,仅记日志,不阻塞切换(重启后首次拉取仍会兜底全量)。
- **写 SharedPreferences 失败**:几乎不抛;Toast 提示用户重试。
## 7. 验证方式
- 构建:`./gradlew assembleDebug` 通过。
- 手动:初始化界面连点 5 次 → 弹窗 → 切「本地」→ 重启 → 抓包/日志确认请求打到新地址;
再切回「测试」验证可恢复。
@@ -0,0 +1,211 @@
# 智能餐盘柜(开柜门版)业务接口对齐文档
> 版本:v1.02026-08-18
> 适用设备:会员版智能餐盘柜(开柜门,applicationId `com.sw.platecabinet.member`
> 用途:供后端对照本设备端实际调用的接口,对齐业务功能与字段
## 1. 通用约定
### 1.1 服务器环境
设备端 BaseUrl 运行时确定,优先级:设备配置接口下发的 `appPackageUrl` > 本地缓存(SP)> 内置默认。
| 环境 | 地址 | 说明 |
| --- | --- | --- |
| 本地 | `http://192.168.10.101:24801` | LOCAL_BASE_URL |
| 测试 | `https://dev.yixiong-tech.com:8081` | TEST_BASE_URL |
| 生产 | `https://platform-api.uat.shuziweidao.com` | PROD_BASE_URL |
### 1.2 请求头(所有请求统一携带)
| Header | 值 | 说明 |
| --- | --- | --- |
| Content-Type | application/json | JSON 请求体 |
| Accept | application/json | — |
| X-Access-Token | 固定 JWT 字符串(当前硬编码) | 设备免登录态 |
| X-DEVICE-CODE | 设备 SN`GlobalData.deviceId` | 设备标识 |
| authorization | 固定 key `57ee87183f2a4fa59683ec9ef41c8f5d` | 网关鉴权 |
### 1.3 统一响应格式
```json
{
"code": "00000", // 成功固定为 "00000",其余视为失败
"msg": "",
"data": { ... } // 业务数据,可为 null / 数组
}
```
## 2. 接口总览
本设备端接口分两套体系(两套设备共用同一后端时请都保留):
| 体系 | 用途 | 状态 |
| --- | --- | --- |
| V2/nutrition/neglect/** | 人脸体系:设备配置、人脸全量/增量同步、人脸采集、取餐上报 | **当前启用** |
| V1/terminal/neglect/** | 开柜门业务:登录、餐盘绑定/解绑、会员搜索、扫描枪查询 | **当前启用** |
| V1 旧人脸接口(faceFeature 系列) | 旧人脸同步 | **已停用**(被 V2 替代,可下线) |
## 3. V2 人脸体系接口(当前启用)
### 3.1 获取设备配置
- **GET** `/nutrition/neglect/pickup/device/config`
- 无参数
- 调用时机:设备启动(DeviceInitActivity),人脸缓存同步之前
响应 `data`DeviceConfigV2):
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| appPackageUrl | String | 业务服务器 BASE URL(下发后全局生效) |
| canteenName | String | 食堂名称 |
| canteenId | String | 食堂 ID |
| arcsoftAppId | String | 虹软 SDK AppID(设备激活用) |
| arcsoftSdkKey | String | 虹软 SDK Key |
| arcsoftActiveKey | String | 虹软 SDK 激活码 |
| clientServerIp | String | MQTT 客户端服务器 IP |
| zhstServerIp | String | 智慧食堂服务器 IPMQTT 端口) |
### 3.2 获取人脸缓存(全量,分页)
- **POST** `/nutrition/neglect/common/face/page`
- 请求体:`{ "pageNum": 1, "pageSize": 100 }`pageSize 固定 100,客户端自动递归翻页直到不足一页)
- 调用时机:设备首次启动(本地标记 isFirstGetFace)时全量拉取,拉取成功后清空本地人脸库重建
响应 `data``UserFaceModelV2` 数组:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| userFaceId | String | 人脸记录唯一 ID(增量删除/判重的关键) |
| userId | String | 用户 ID |
| faceFeature / faceFeatureStr / faceFeatureString | String | 人脸特征 Base64(三字段取第一个非空) |
| faceUpdateTimestamp | Long | 人脸更新时间戳(毫秒) |
| cardNo | String | 会员编号 |
| member | Boolean | 是否会员 |
| faceDeleted | Boolean | 删除标识(增量接口用) |
| personType | String | 人员类型 |
### 3.3 获取人脸增量数据
- **POST** `/nutrition/neglect/common/face/increment`
- 请求体:`{ "pageNum": 1, "pageSize": 100, "timestamp": 1723900000000 }`(timestamp 为上次同步的最大 faceUpdateTimestamp
- 调用时机:登录页每 5 分钟定时任务(人脸库非空时)
- 客户端处理:`faceDeleted=true``userFaceId` 删单条;否则按 `userFaceId` 判重后插入;同步完成后保存最大时间戳(只升不降)
- 响应:同 3.2 的 `UserFaceModelV2` 数组
### 3.4 上传人脸照片(预留,本版本未启用)
- **POST** `/nutrition/neglect/upload`multipart/form-data,字段名 `file`
- 响应 `data`:图片 URLString
### 3.5 新增人脸数据(预留,本版本未启用)
- **POST** `/nutrition/neglect/user/add-by-face`
- 请求体:`{ "url": "<图片URL>", "featureChar": "<特征Base64>" }`
- 响应 `data``UserFaceModelV2`(含服务端生成的 userFaceId / userId
### 3.6 取餐盘时刻上报(预留,本版本未启用)
- **POST** `/nutrition/neglect/pickup/plate-pickup`
- 请求体:`{ "id": <用户id> }`
- 响应 `data`:主单 recordNoString
- 说明:吐盘机 5.0 在识别成功出盘后调用;开柜门版暂未启用,后端可先行保留
## 4. V1 开柜门业务接口(当前启用)
> 基础路径 `/terminal/neglect`,本设备核心业务流。
### 4.1 登录(获取餐盘用户信息)
- **POST** `/terminal/neglect/sideboard/app/getPlateBoxUserInfo`
- 请求体(LoginParam):
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| equipmentId | Int | 设备 ID |
| faceId | String | 会员人脸 ID(人脸识别登录时传) |
| phone | String | 手机号(密码登录时传) |
| password | String | 密码(密码登录时传) |
- 调用时机:人脸识别成功(相似度 ≥ 0.8,3 秒防抖)/ 手机号密码登录
响应 `data`EquipmentUserInfo):
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| id | Long | 绑定记录主键(解绑时使用) |
| equipmentId | String | 设备 ID |
| equipmentCode | String | 设备编号 |
| equipmentBoxCode | String | 柜门/格子编号(**非空即执行开柜门**,转 Int 后下发串口) |
| faceId | String | 会员人脸 ID(非 null 表示已绑定餐盘) |
| plateNumber | String | 餐盘编号 |
| equipmentName | String | 设备名称 |
| orderNo | String | 订单号 |
| updateTime | String | 更新时间 |
| name / phone / faceUrl | String | 会员基础信息 |
| mealTime / mealTimeInterval | String | 就餐时段 |
| openTime / openTimeInterval | String | 开柜时段 |
| eatCount | Int | 就餐次数 |
| cardBalance | Double | 卡余额(≤0 弹余额不足提示并拦截) |
### 4.2 查询设备下的绑定列表
- **GET** `/terminal/neglect/sideboard/app/getYxMemberRefPlateByEquipmentCode`(设备编号走 `X-DEVICE-CODE` 头)
- 调用时机:管理员登录且未绑定格子时,取可绑定的空格子
- 响应 `data`EquipmentUserInfo 数组
### 4.3 餐盘绑定
- **POST** `/terminal/neglect/sideboard/app/plateBinding`
- 请求体(BindParam):
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| equipmentId | String | 设备 ID |
| equipmentCode | String | 设备编号 |
| equipmentBoxCode | String | 格子编号 |
| memberId | String | 会员 ID |
| faceId | String | 会员人脸 ID |
| plateNumber | String | 餐盘编号 |
- 响应 `data`EquipmentUserInfo
### 4.4 餐盘解绑
- **POST** `/terminal/neglect/sideboard/app/plateUnbind`form-urlencoded
- 参数:`id`Long,绑定记录主键)
- 响应 `data`:无
### 4.5 会员模糊搜索
- **POST** `/terminal/neglect/common/app/getUserInfoByNameOrPhone`
- 请求体:`{ "pageNum": 0, "pageSize": 0, "name": "张", "phone": "138..." }`
- 响应 `data`Member 数组):`id``faceId``name``phone`
- 调用时机:管理界面按姓名/手机号搜索会员
### 4.6 按餐盘号查询(扫描枪)
- **GET** `/terminal/neglect/sideboard/app/getMemberRefPlateByPlateNumber?plateNumber=xxx`
- 响应 `data`EquipmentUserInfo
- 调用时机:扫描枪扫餐盘码
## 5. 已停用的 V1 旧接口(可下线)
以下接口在本次人脸体系切换(V2)后**设备端已无调用**,后端对齐时可安排下线:
| 接口 | 路径 |
| --- | --- |
| 旧人脸缓存(V1 | POST `/terminal/neglect/common/app/faceFeature/list` |
| 旧人脸增量(V1 | POST `/terminal/neglect/common/app/faceFeature/increment/list` |
| 旧设备配置(V1 | GET `/terminal/neglect/common/app/getYxEquipmentByEquipmentCode` |
## 6. 设备端业务主流程(供对照)
1. 启动 → 拉取设备配置(V2 3.1,注入虹软激活参数、BaseUrl)
2. 首次启动全量拉取人脸(V2 3.2,分页递归重建本地库)
3. 进入人脸识别登录页,每 5 分钟增量同步(V2 3.3)
4. 人脸识别成功(阈值 0.8)→ 登录接口(V1 4.1)查会员与绑定信息
5. 已绑格子(equipmentBoxCode 非空)→ 串口开柜门 → 跳转结果页
6. 余额不足(cardBalance ≤ 0)→ 弹窗拦截
7. 管理员路径:搜索会员(4.5)/ 扫描枪(4.6)→ 绑定(4.3)/ 解绑(4.4)格子
+2
View File
@@ -22,6 +22,7 @@ okhttp = "4.12.0"
timber = "5.0.1"
gson = "2.13.1"
hiltAndroid = "2.56.2"
paho = "1.2.5"
core = "3.4.1"
androidCore = "3.3.0"
@@ -58,6 +59,7 @@ okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
converter-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
paho-mqtt = { group = "org.eclipse.paho", name = "org.eclipse.paho.client.mqttv3", version.ref = "paho" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
+2 -2
View File
@@ -13,7 +13,7 @@ android {
consumerProguardFiles("consumer-rules.pro")
ndk {
abiFilters.addAll(listOf("armeabi-v7a"/*, "arm64-v8a"*/))
abiFilters.addAll(listOf("armeabi-v7a", "arm64-v8a"))
}
}
@@ -62,6 +62,6 @@ dependencies {
implementation("com.google.code.gson:gson:2.8.6")
val glideVersion = "4.12.0"
implementation("com.github.bumptech.glide:glide:$glideVersion")
api("com.github.bumptech.glide:glide:$glideVersion")
annotationProcessor("com.github.bumptech.glide:compiler:$glideVersion")
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2,6 +2,8 @@ package com.sw.plate.utils;
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.View;
@@ -30,18 +32,20 @@ public class ToastUtils {
* @param text the text
*/
public static void showToast(String text) {
Context context = App.getContext();
if (toast == null) {
View view = LayoutInflater.from(context).inflate(R.layout.toast_bg, null);
textCenterView = view.findViewById(R.id.toast_tv);
toast = new Toast(context);
toast.setGravity(Gravity.CENTER, 0, 20);
toast.setDuration(Toast.LENGTH_SHORT);
toast.setView(view);
}
new Handler(Looper.getMainLooper()).post(()->{
Context context = App.getContext();
if (toast == null) {
View view = LayoutInflater.from(context).inflate(R.layout.toast_bg, null);
textCenterView = view.findViewById(R.id.toast_tv);
toast = new Toast(context);
toast.setGravity(Gravity.CENTER, 0, 20);
toast.setDuration(Toast.LENGTH_SHORT);
toast.setView(view);
}
textCenterView.setText(text);
toast.show();
textCenterView.setText(text);
toast.show();
});
}
/**
@@ -87,10 +87,10 @@ public class ConfigUtil {
/**
* 默认相机分辨率
*/
private static final String DEFAULT_PREVIEW_SIZE = "1280x720";
// private static final String DEFAULT_PREVIEW_SIZE = "1280x720";
private static final String DEFAULT_PREVIEW_SIZE = "1024x768";
// private static final String DEFAULT_PREVIEW_SIZE = "400x640";
/**
* 获取String类型的preference
*
@@ -32,14 +32,20 @@ public class FaceApi {
*/
public void updateFaceData(int index, List<FaceEntity> list) {
Log.d(TAG, "updateFaceData: index = " + index + ", listSize = " + list.size());
FaceDao faceDao = FaceDatabase.getInstance(App.getContext()).faceDao();
if (index == 1) {
faceDao.deleteAll();
faceDao.resetId();
}
FaceDao faceDao = getFaceDao();
//if (index == 1) {
// faceDao.deleteAll();
// faceDao.resetId();
//}
faceDao.insert(list);
}
public void clearFaceData() {
FaceDao faceDao = getFaceDao();
faceDao.deleteAll();
faceDao.resetId();
}
/**
* 激活arcsoft 人脸
*
@@ -82,24 +88,82 @@ public class FaceApi {
}
public void deleteByUserName(String userName) {
FaceDao faceDao = FaceDatabase.getInstance(App.getContext()).faceDao();
faceDao.deleteFaceById(userName);
getFaceDao().deleteFaceById(userName);
}
/**
* 按 userFaceId 删除单条人脸记录
*/
public void deleteByUserFaceId(String userFaceId) {
getFaceDao().deleteFaceByUserFaceId(userFaceId);
}
/**
* 按 userFaceId 查询单条人脸记录
*/
public FaceEntity queryByUserFaceId(String userFaceId) {
if (userFaceId == null || userFaceId.isEmpty()) {
return null;
}
return getFaceDao().queryByUserFaceId(userFaceId);
}
public Long insert(FaceEntity entity) {
if (entity == null) {
return 0L;
}
FaceDao faceDao = FaceDatabase.getInstance(App.getContext()).faceDao();
return faceDao.insert(entity);
return getFaceDao().insert(entity);
}
public FaceEntity queryByUserName(String userName) {
if (TextUtils.isEmpty(userName)) {
return null;
}
FaceDao faceDao = FaceDatabase.getInstance(App.getContext()).faceDao();
return faceDao.queryByUserName(userName);
return getFaceDao().queryByUserName(userName);
}
public int queryFaceCount() {
return getFaceDao().getFaceCount();
}
public List<FaceEntity> queryAllByUserName(String userName) {
return getFaceDao().queryAllByUserName(userName);
}
/**
* 按服务端更新时间倒序取最近更新的 N 条人脸记录(运维面板展示)
*/
public List<FaceEntity> queryRecentUpdatedFaces(int limit) {
return getFaceDao().getRecentUpdatedFaces(limit);
}
/**
* 库内最大的服务端更新时间戳(无记录返回 null)
*/
public Long queryMaxFaceUpdateTimestamp() {
return getFaceDao().getMaxFaceUpdateTimestamp();
}
/**
* 按 userId 精确过滤(运维面板搜索用)
*/
public List<FaceEntity> queryByUserId(String userId, int limit) {
if (TextUtils.isEmpty(userId)) {
return new java.util.ArrayList<>();
}
return getFaceDao().queryByUserId(userId, limit);
}
/**
* 按 member 布尔统计人脸数(true-会员,false-非会员)
*/
public int queryFaceCountByMember(boolean member) {
return getFaceDao().getFaceCountByMember(member);
}
public FaceDao getFaceDao() {
return FaceDatabase.getInstance(App.getContext()).faceDao();
}
}
@@ -77,7 +77,6 @@ public class FaceRectTransformer {
rect.bottom *= verticalRatio;
Rect newRect = new Rect();
L.e("cameraDisplayOrientation " + cameraDisplayOrientation + " === " + cameraId);
switch (cameraDisplayOrientation) {
case 0:
if (cameraId == Camera.CameraInfo.CAMERA_FACING_FRONT) {
@@ -46,6 +46,10 @@ public class FaceRectView extends View {
}
}
public int getRectColor() {
return paint.getColor();
}
public void clearFaceInfo() {
drawInfoList.clear();
postInvalidate();
@@ -5,6 +5,7 @@ import android.hardware.Camera;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.widget.Toast;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
@@ -18,6 +19,7 @@ import com.arcsoft.face.ImageQualitySimilar;
import com.arcsoft.face.LivenessInfo;
import com.arcsoft.face.MaskInfo;
import com.arcsoft.face.enums.ExtractType;
import com.sw.plate.App;
import com.sw.plate.utils.L;
import com.sw.plate.utils.arcface.FaceRectTransformer;
import com.sw.plate.utils.arcface.face.constants.LivenessType;
@@ -37,7 +39,6 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Map;
@@ -102,6 +103,10 @@ public class FaceHelper implements FaceListener {
* 活体检测引擎
*/
private FaceEngine flEngine;
/**
* 口罩检测引擎(5.0 独立引擎)
*/
private FaceEngine maskEngine;
private Camera.Size previewSize;
@@ -146,11 +151,6 @@ public class FaceHelper implements FaceListener {
*/
private boolean onlyDetectLiveness;
/**
* 是否需要更新faceData
*/
private boolean needUpdateFaceData;
/**
* 识别的配置项
*/
@@ -180,9 +180,9 @@ public class FaceHelper implements FaceListener {
}
private FaceHelper(Builder builder) {
needUpdateFaceData = builder.needUpdateFaceData;
onlyDetectLiveness = builder.onlyDetectLiveness;
ftEngine = builder.ftEngine;
maskEngine = builder.maskEngine;
trackedFaceCount = builder.trackedFaceCount;
previewSize = builder.previewSize;
frEngine = builder.frEngine;
@@ -246,7 +246,6 @@ public class FaceHelper implements FaceListener {
* @param format 图像格式
*/
public void requestFaceFeature(byte[] nv21, FacePreviewInfo facePreviewInfo, int width, int height, int format) {
L.e("requestFaceFeature===frThreadQueue.remainingCapacity()=" + frThreadQueue.remainingCapacity());
if (frEngine != null && frThreadQueue.remainingCapacity() > 0) {
frExecutor.execute(new FaceRecognizeRunnable(nv21, facePreviewInfo, width, height, format));
} else {
@@ -341,12 +340,20 @@ public class FaceHelper implements FaceListener {
refreshTrackId(faceInfoList);
if (faceInfoList.isEmpty()) {
return facePreviewInfoList;
} else {
FaceInfo currentFaceInfo = faceInfoList.get(0);
// L.e("currentFaceInfo width=" + currentFaceInfo.getRect().width() +
// "---height" + currentFaceInfo.getRect().height());
if (currentFaceInfo.getRect().width() < 200) {//距离远(人脸小),不识别
clearLeftFace(facePreviewInfoList);
return facePreviewInfoList;
}
}
if (!onlyDetectLiveness) {
code = ftEngine.process(rgbNv21, previewSize.width, previewSize.height, FaceEngine.CP_PAF_NV21, faceInfoList,
if (!onlyDetectLiveness && maskEngine != null) {
code = maskEngine.process(rgbNv21, previewSize.width, previewSize.height, FaceEngine.CP_PAF_NV21, faceInfoList,
FaceEngine.ASF_MASK_DETECT);
if (code == ErrorInfo.MOK) {
code = ftEngine.getMask(maskInfoList);
code = maskEngine.getMask(maskInfoList);
if (code != ErrorInfo.MOK) {
onFail(new Exception("process getMask failed,code is " + code));
return facePreviewInfoList;
@@ -652,21 +659,52 @@ public class FaceHelper implements FaceListener {
});
}
private int failCount = 0;
private long startTime = 0;
private void searchFace(final FaceFeature faceFeature, final Integer trackId) {
CompareResult compareResult = FaceServer.getInstance().searchFaceFeature(faceFeature, frEngine);
if (compareResult == null || compareResult.getFaceEntity() == null) {
if (startTime == 0) {
failCount = 0;
startTime = System.currentTimeMillis();
}
if (System.currentTimeMillis() - startTime > 10*1000) {
failCount = 0;
startTime = System.currentTimeMillis();
}
failCount++;
float similar;
if (compareResult != null) {
similar = compareResult.getSimilar();
} else {
similar = 0f;
}
// new Handler(Looper.getMainLooper()).post(()-> {
// try {
// Toast.makeText(App.getContext(),"识别失败"+failCount+"次,similar="+similar, Toast.LENGTH_SHORT).show();
// } catch (Exception e) {
// e.printStackTrace();
// }
// });
Log.d(TAG, "collectFace,searchFace查询失败"+failCount+"次,similar="+similar);
if (failCount >= 2) {
recognizeCallback.onRecognized(null, LivenessInfo.UNKNOWN, false);
return;
}
retryRecognizeDelayed(trackId);
return;
}
compareResult.setTrackId(trackId);
boolean pass = compareResult.getSimilar() > recognizeConfiguration.getSimilarThreshold();
compareResult.setSimilarPass(pass);
Log.d(TAG, "collectFace,searchFace: pass="+pass+",similar="+compareResult.getSimilar()+",threshold="+recognizeConfiguration.getSimilarThreshold());
recognizeCallback.onRecognized(compareResult, getRecognizeInfo(recognizeInfoMap, trackId).getLiveness(), pass);
if (pass) {
setName(trackId, "识别通过");
noticeCurrentStatus("识别通过");
changeRecognizeStatus(trackId, RequestFeatureStatus.SUCCEED);
} else {
noticeCurrentStatus("未通过:NOT_REGISTERED");
noticeCurrentStatus("未通过:NOT_REGISTERED"+compareResult.getSimilar());
retryRecognizeDelayed(trackId);
}
}
@@ -839,18 +877,7 @@ public class FaceHelper implements FaceListener {
int fdCode = flEngine.detectFaces(nv21Data, width, height, format, faceInfoList);
boolean isFaceExists = isFaceExists(faceInfoList, faceInfo);
if (fdCode == ErrorInfo.MOK && isFaceExists) {
if (needUpdateFaceData) {
/*
* 若IR人脸框有偏移,则需要对IR的人脸数据进行updateFaceData处理,再将处理后的FaceInfo信息传输给活体检测接口
*/
flCode = flEngine.updateFaceData(nv21Data, previewSize.width, previewSize.height, FaceEngine.CP_PAF_NV21,
new ArrayList<>(Collections.singletonList(faceInfo)));
if (flCode == ErrorInfo.MOK) {
flCode = flEngine.processIr(nv21Data, width, height, format, Arrays.asList(faceInfo), FaceEngine.ASF_IR_LIVENESS);
}
} else {
flCode = flEngine.processIr(nv21Data, width, height, format, Arrays.asList(faceInfo), FaceEngine.ASF_IR_LIVENESS);
}
flCode = flEngine.processIr(nv21Data, width, height, format, Arrays.asList(faceInfo), FaceEngine.ASF_IR_LIVENESS);
} else {
onFail(new Exception("ir detectFaces failed fdCode:" + fdCode + ",isFaceExists:" + isFaceExists));
}
@@ -1057,9 +1084,9 @@ public class FaceHelper implements FaceListener {
private FaceEngine ftEngine;
private FaceEngine frEngine;
private FaceEngine flEngine;
private FaceEngine maskEngine;
private Camera.Size previewSize;
private boolean onlyDetectLiveness;
private boolean needUpdateFaceData;
private RecognizeConfiguration recognizeConfiguration;
private RecognizeCallback recognizeCallback;
private IDualCameraFaceInfoTransformer dualCameraFaceInfoTransformer;
@@ -1100,6 +1127,11 @@ public class FaceHelper implements FaceListener {
return this;
}
public Builder maskEngine(FaceEngine val) {
maskEngine = val;
return this;
}
public Builder previewSize(Camera.Size val) {
previewSize = val;
return this;
@@ -1125,11 +1157,6 @@ public class FaceHelper implements FaceListener {
return this;
}
public Builder needUpdateFaceData(boolean val) {
needUpdateFaceData = val;
return this;
}
public FaceHelper build() {
return new FaceHelper(this);
}
@@ -10,6 +10,16 @@ public class CompareResult {
private int compareCode;
private long cost;
private boolean similarPass;
public void setSimilarPass(boolean similarPass) {
this.similarPass = similarPass;
}
public boolean isSimilarPass() {
return similarPass;
}
public CompareResult(FaceEntity faceEntity, float similar) {
this.faceEntity = faceEntity;
this.similar = similar;
@@ -9,7 +9,7 @@ import androidx.room.RoomDatabase;
import com.sw.plate.utils.arcface.facedb.dao.FaceDao;
import com.sw.plate.utils.arcface.facedb.entity.FaceEntity;
@Database(entities = {FaceEntity.class}, version = 1, exportSchema = false)
@Database(entities = {FaceEntity.class}, version = 2, exportSchema = false)
public abstract class FaceDatabase extends RoomDatabase {
public abstract FaceDao faceDao();
@@ -22,7 +22,10 @@ public abstract class FaceDatabase extends RoomDatabase {
faceDatabase = Room.databaseBuilder(context, FaceDatabase.class,
context.getDatabasePath("faceDB.db").getPath()
// context.getExternalFilesDir("database") + File.separator + "faceDB.db"
).build();
)
// 5.0 特征模型不兼容,升级时直接删除旧表重建
.fallbackToDestructiveMigration()
.build();
}
}
}
@@ -55,6 +55,18 @@ public interface FaceDao {
@Query("DELETE from face WHERE user_name = :userName")
int deleteFaceById(String userName);
/**
* 按 userFaceId 删除单条人脸记录
*/
@Query("DELETE from face WHERE user_face_id = :userFaceId")
int deleteFaceByUserFaceId(String userFaceId);
/**
* 按 userFaceId 查询单条人脸记录
*/
@Query("SELECT * FROM face WHERE user_face_id = :userFaceId LIMIT 1")
FaceEntity queryByUserFaceId(String userFaceId);
/**
* 删除所有已注册的人脸
*
@@ -91,4 +103,50 @@ public interface FaceDao {
@Query("SELECT * FROM face WHERE user_name = :userName limit 1")
FaceEntity queryByUserName(String userName);
/**
* 查询指定用户的所有人脸记录(同一用户可能存有多条特征数据)
*
* @param userName 用户ID
* @return 该用户所有人脸记录列表
*/
@Query("SELECT * FROM face WHERE user_name = :userName")
List<FaceEntity> queryAllByUserName(String userName);
/**
* @return 删除临时用户人脸
*/
@Query("DELETE from face WHERE user_type = :userType")
int deleteUserFaceData(int userType);
/**
* 查询用户人脸数
* @param userType 1-会员,2-临时用户
*/
@Query("SELECT COUNT(1) FROM face WHERE user_type = :userType")
int getFaceCountByUserType(int userType);
/**
* 按服务端更新时间倒序取最近更新的 N 条人脸记录(运维面板展示)
*/
@Query("SELECT * FROM face ORDER BY face_update_timestamp DESC LIMIT :limit")
List<FaceEntity> getRecentUpdatedFaces(int limit);
/**
* 库内最大的服务端更新时间戳(无记录时返回 null)
*/
@Query("SELECT MAX(face_update_timestamp) FROM face")
Long getMaxFaceUpdateTimestamp();
/**
* 按 userId 精确过滤(运维面板搜索用)
*/
@Query("SELECT * FROM face WHERE user_id = :userId ORDER BY faceId DESC LIMIT :limit")
List<FaceEntity> queryByUserId(String userId, int limit);
/**
* 按 member 布尔统计人脸数(true-会员,false-非会员;比 user_type 更可靠,user_type 存的是服务端 personType 字符串)
*/
@Query("SELECT COUNT(1) FROM face WHERE member = :member")
int getFaceCountByMember(boolean member);
}
@@ -45,14 +45,46 @@ public class FaceEntity implements Parcelable {
@ColumnInfo(name = "register_time")
private long registerTime;
/**
* 用户类型:1-普通会员、2-临时用户、3-内部员工、或者其它待定类型
* 用户类型:1-普通会员、2-临时用户、或者其它待定类型
*/
@ColumnInfo(name = "user_type")
private String userType;
/**
* 会员编号
*/
@ColumnInfo(name = "card_no")
private String cardNo;
@Ignore
private int trackId;//人脸追踪ID
@ColumnInfo(name = "user_id")
private String userId;
@ColumnInfo(name = "user_face_id")
private String userFaceId;
@ColumnInfo(name = "member")
private boolean member;
@ColumnInfo(name = "face_update_timestamp")
private long faceUpdateTimestamp;
public FaceEntity() {
registerTime = System.currentTimeMillis();
}
@Ignore
public FaceEntity(String userName, byte[] featureData, String userType, String cardNo, String userId, String userFaceId, boolean member, long faceUpdateTimestamp) {
this.userName = userName;
this.featureData = featureData;
this.userType = userType;
this.cardNo = cardNo;
this.userId = userId;
this.userFaceId = userFaceId;
this.member = member;
this.faceUpdateTimestamp = faceUpdateTimestamp;
registerTime = System.currentTimeMillis();
}
@Ignore
public FaceEntity(String userName, String imagePath, byte[] featureData) {
this.userName = userName;
this.imagePath = imagePath;
@@ -60,21 +92,35 @@ public class FaceEntity implements Parcelable {
registerTime = System.currentTimeMillis();
}
@Ignore
public FaceEntity(FaceEntity faceEntity) {
this.faceId = faceEntity.faceId;
this.userName = faceEntity.userName;
this.imagePath = faceEntity.imagePath;
this.featureData = faceEntity.featureData;
this.registerTime = faceEntity.registerTime;
this.userType = faceEntity.getUserType();
this.cardNo = faceEntity.getCardNo();
this.userId = faceEntity.getUserId();
this.userFaceId = faceEntity.getUserFaceId();
this.member = faceEntity.isMember();
this.faceUpdateTimestamp = faceEntity.getFaceUpdateTimestamp();
}
@Ignore
protected FaceEntity(Parcel in) {
faceId = in.readLong();
registerTime = in.readLong();
userName = in.readString();
imagePath = in.readString();
featureData = in.createByteArray();
userType = in.readString();
cardNo = in.readString();
userId = in.readString();
userFaceId = in.readString();
member = in.readByte() != 0;
faceUpdateTimestamp = in.readLong();
}
public static final Creator<FaceEntity> CREATOR = new Creator<FaceEntity>() {
@@ -145,6 +191,46 @@ public class FaceEntity implements Parcelable {
this.userType = userType;
}
public String getCardNo() {
return cardNo;
}
public void setCardNo(String cardNo) {
this.cardNo = cardNo;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getUserFaceId() {
return userFaceId;
}
public void setUserFaceId(String userFaceId) {
this.userFaceId = userFaceId;
}
public boolean isMember() {
return member;
}
public void setMember(boolean member) {
this.member = member;
}
public long getFaceUpdateTimestamp() {
return faceUpdateTimestamp;
}
public void setFaceUpdateTimestamp(long faceUpdateTimestamp) {
this.faceUpdateTimestamp = faceUpdateTimestamp;
}
@Override
public int describeContents() {
return 0;
@@ -158,10 +244,13 @@ public class FaceEntity implements Parcelable {
dest.writeString(imagePath);
dest.writeByteArray(featureData);
dest.writeString(userType);
dest.writeString(cardNo);
dest.writeString(userId);
dest.writeString(userFaceId);
dest.writeByte((byte) (member ? 1 : 0));
dest.writeLong(faceUpdateTimestamp);
}
@Override
public boolean equals(Object o) {
if (this == o) {
@@ -176,12 +265,17 @@ public class FaceEntity implements Parcelable {
TextUtils.equals(this.userName, that.userName) &&
TextUtils.equals(this.imagePath, that.imagePath) &&
Arrays.equals(featureData, that.featureData) &&
TextUtils.equals(this.userType, that.userType);
TextUtils.equals(this.userType, that.userType) &&
TextUtils.equals(this.cardNo, that.cardNo) &&
TextUtils.equals(this.userId, that.userId) &&
TextUtils.equals(this.userFaceId, that.userFaceId) &&
this.member == that.member &&
this.faceUpdateTimestamp == that.faceUpdateTimestamp;
}
@Override
public int hashCode() {
int result = Objects.hash(faceId, registerTime, userName, imagePath, userType);
int result = Objects.hash(faceId, registerTime, userName, imagePath, userType, cardNo, userId, userFaceId, member, faceUpdateTimestamp);
result = 31 * result + Arrays.hashCode(featureData);
return result;
}
@@ -12,7 +12,6 @@ import androidx.lifecycle.ViewModel;
import com.arcsoft.face.AgeInfo;
import com.arcsoft.face.ErrorInfo;
import com.arcsoft.face.FaceAttributeParam;
import com.arcsoft.face.FaceEngine;
import com.arcsoft.face.FaceInfo;
import com.arcsoft.face.GenderInfo;
@@ -132,13 +131,14 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
private MutableLiveData<Integer> ftInitCode = new MutableLiveData<>();
private MutableLiveData<Integer> frInitCode = new MutableLiveData<>();
private MutableLiveData<Integer> flInitCode = new MutableLiveData<>();
private MutableLiveData<Integer> maskInitCode = new MutableLiveData<>();
/**
* 人脸操作辅助类,推帧即可,内部会进行特征提取、识别
*/
private FaceHelper faceHelper;
/**
* VIDEO模式人脸检测引擎,用于预览帧人脸追踪及图像质量检测
* VIDEO模式人脸检测引擎,用于预览帧人脸追踪
*/
private FaceEngine ftEngine;
/**
@@ -149,6 +149,10 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
* IMAGE模式活体检测引擎,用于预览帧人脸活体检测
*/
private FaceEngine flEngine;
/**
* IMAGE模式口罩检测引擎(5.0 独立引擎)
*/
private FaceEngine maskEngine;
private PreviewConfig previewConfig;
@@ -158,12 +162,8 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
private MutableLiveData<String> drawRectInfoText = new MutableLiveData<>();
private MutableLiveData<String> recognizeUserId = new MutableLiveData<>();
private MutableLiveData<CompareResult> recognizeUserId = new MutableLiveData<>();
/**
* 检测ir活体前,是否需要更新faceData
*/
private boolean needUpdateFaceData;
/**
* 当前活体检测的检测类型
*/
@@ -280,6 +280,8 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
// 填入在设置界面设置好的配置信息
boolean enableLive = !ConfigUtil.getLivenessDetectType(context).equals(context.getString(R.string.value_liveness_type_disable));
enableLive = false;
boolean enableFaceQualityDetect = ConfigUtil.isEnableImageQualityDetect(context);
boolean enableFaceMoveLimit = ConfigUtil.isEnableFaceMoveLimit(context);
boolean enableFaceSizeLimit = ConfigUtil.isEnableFaceSizeLimit(context);
@@ -295,22 +297,22 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
.similarThreshold(ConfigUtil.getRecognizeThreshold(context))
.imageQualityNoMaskRecognizeThreshold(ConfigUtil.getImageQualityNoMaskRecognizeThreshold(context))
.imageQualityMaskRecognizeThreshold(ConfigUtil.getImageQualityMaskRecognizeThreshold(context))
.livenessParam(new LivenessParam(ConfigUtil.getRgbLivenessThreshold(context), ConfigUtil.getIrLivenessThreshold(context),
ConfigUtil.getLivenessFqThreshold(context)))
.livenessParam(new LivenessParam(ConfigUtil.getRgbLivenessThreshold(context), ConfigUtil.getIrLivenessThreshold(context)))
.build();
int cameraOffsetX = ConfigUtil.getDualCameraHorizontalOffset(context);
int cameraOffsetY = ConfigUtil.getDualCameraVerticalOffset(context);
needUpdateFaceData = (livenessType == LivenessType.IR && (cameraOffsetX != 0 || cameraOffsetY != 0));
// 人脸追踪引擎(VIDEO模式,仅检测)
ftEngine = new FaceEngine();
int ftEngineMask = FaceEngine.ASF_FACE_DETECT | FaceEngine.ASF_MASK_DETECT;
int ftEngineMask = FaceEngine.ASF_FACE_DETECT;
ftInitCode.postValue(ftEngine.init(context, DetectMode.ASF_DETECT_MODE_VIDEO, ConfigUtil.getFtOrient(context),
ConfigUtil.getRecognizeMaxDetectFaceNum(context), ftEngineMask));
FaceAttributeParam attributeParam = new FaceAttributeParam(
ConfigUtil.getRecognizeEyeOpenThreshold(context), ConfigUtil.getRecognizeMouthCloseThreshold(context),
ConfigUtil.getRecognizeWearGlassesThreshold(context));
ftEngine.setFaceAttributeParam(attributeParam);
// 口罩检测引擎(5.0 独立引擎)
maskEngine = new FaceEngine();
maskInitCode.postValue(maskEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE,
DetectFaceOrientPriority.ASF_OP_ALL_OUT,
ConfigUtil.getRecognizeMaxDetectFaceNum(context), FaceEngine.ASF_MASK_DETECT));
// 特征提取引擎
frEngine = new FaceEngine();
int frEngineMask = FaceEngine.ASF_FACE_RECOGNITION;
if (enableFaceQualityDetect) {
@@ -320,16 +322,13 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
10, frEngineMask));
FaceServer.getInstance().initFaceList(context, frEngine, faceCount -> loadFaceList = true, true);
//启用活体检测时,才初始化活体引擎
// 启用活体检测时,才初始化活体引擎
if (enableLive) {
flEngine = new FaceEngine();
int flEngineMask = (livenessType == LivenessType.RGB ? FaceEngine.ASF_LIVENESS : (FaceEngine.ASF_IR_LIVENESS | FaceEngine.ASF_FACE_DETECT));
if (needUpdateFaceData) {
flEngineMask |= FaceEngine.ASF_UPDATE_FACEDATA;
}
flInitCode.postValue(flEngine.init(context, DetectMode.ASF_DETECT_MODE_IMAGE,
DetectFaceOrientPriority.ASF_OP_ALL_OUT, 10, flEngineMask));
LivenessParam livenessParam = new LivenessParam(ConfigUtil.getRgbLivenessThreshold(context), ConfigUtil.getIrLivenessThreshold(context), ConfigUtil.getLivenessFqThreshold(context));
LivenessParam livenessParam = new LivenessParam(ConfigUtil.getRgbLivenessThreshold(context), ConfigUtil.getIrLivenessThreshold(context));
flEngine.setLivenessParam(livenessParam);
}
@@ -354,19 +353,25 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
if (ftEngine != null) {
synchronized (ftEngine) {
int ftUnInitCode = ftEngine.unInit();
Log.i(TAG, "unInitEngine: " + ftUnInitCode);
Log.i(TAG, "unInitEngine ft: " + ftUnInitCode);
}
}
if (maskEngine != null) {
synchronized (maskEngine) {
int maskUnInitCode = maskEngine.unInit();
Log.i(TAG, "unInitEngine mask: " + maskUnInitCode);
}
}
if (frEngine != null) {
synchronized (frEngine) {
int frUnInitCode = frEngine.unInit();
Log.i(TAG, "unInitEngine: " + frUnInitCode);
Log.i(TAG, "unInitEngine fr: " + frUnInitCode);
}
}
if (flEngine != null) {
synchronized (flEngine) {
int flUnInitCode = flEngine.unInit();
Log.i(TAG, "unInitEngine: " + flUnInitCode);
Log.i(TAG, "unInitEngine fl: " + flUnInitCode);
}
}
}
@@ -395,6 +400,20 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
}
}
/**
* 重置人脸识别状态:清空上次识别结果及 FaceHelper 内部的 recognizeInfoMap
* 使下一帧进入时能重新触发识别流程。
* 适用场景:短时间内再次识别、点击重试按钮等需要重新开始识别的时机。
*/
public void resetFaceState() {
// 清空粘性 LiveData,防止旧结果被重新投递给 observer
recognizeUserId.postValue(null);
// 清空 FaceHelper 内部所有人脸状态,让 trackId 对应的状态回到 TO_RETRY
if (faceHelper != null) {
faceHelper.clearFacePreviewInfoList();
}
}
/**
* 释放操作
*/
@@ -453,7 +472,7 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
.ftEngine(ftEngine)
.frEngine(frEngine)
.flEngine(flEngine)
.needUpdateFaceData(needUpdateFaceData)
.maskEngine(maskEngine)
.frQueueSize(maxDetectFaceNum)
.flQueueSize(maxDetectFaceNum)
.previewSize(previewSize)
@@ -469,13 +488,20 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
}
}
private String getUserId(CompareResult result) {
if (result != null && result.getFaceEntity() != null) {
return result.getFaceEntity().getUserName();
}
return null;
}
@Override
public void onRecognized(CompareResult compareResult, Integer live, boolean similarPass) {
Disposable disposable = Observable.just(true).observeOn(AndroidSchedulers.mainThread()).subscribe(aBoolean -> {
// TODO: 2026/1/21 测试使用 Observable.just(similarPass)代替Observable.just(true)---
Observable.just(similarPass).observeOn(AndroidSchedulers.mainThread()).subscribe(aBoolean -> {
Log.d(TAG, "collectFace,onRecognized: similarPass=" + similarPass + ",live=" + live + ",userId=" + getUserId(compareResult));
if (similarPass) {
if (recognizeUserId != null) {
recognizeUserId.postValue(compareResult.getFaceEntity().getUserName());
}
recognizeUserId.postValue(compareResult);
boolean isAdded = false;
List<CompareResult> compareResults = compareResultList.getValue();
if (compareResults != null && !compareResults.isEmpty()) {
@@ -497,6 +523,8 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
getFaceItemEventMutableLiveData().postValue(new FaceItemEvent(compareResults.size() - 1, EventType.INSERTED));
}
}
} else {
recognizeUserId.postValue(compareResult);
}
});
}
@@ -540,7 +568,7 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
return recognizeNotice;
}
public MutableLiveData<String> getRecognizeUserId() {
public MutableLiveData<CompareResult> getRecognizeUserId() {
return recognizeUserId;
}
@@ -561,7 +589,7 @@ public class RecognizeViewModel extends ViewModel implements RecognizeCallback {
}
}
private void updateRegisterStatus(int status) {
public void updateRegisterStatus(int status) {
registerStatus = status;
}
@@ -44,4 +44,25 @@ public class SerialApi {
serialPortManager.close();
}
}
/**
* 串口是否已打开(运维面板展示用)
*/
public static boolean isOpened() {
return serialPort != null;
}
/**
* 串口设备路径(运维面板展示用)
*/
public static String getPath() {
return path;
}
/**
* 串口波特率(运维面板展示用)
*/
public static int getBaudRate() {
return speed;
}
}
+3 -3
View File
@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:cardCornerRadius="5dp"
app:cardPreventCornerOverlap="true">
@@ -10,7 +10,7 @@
<TextView
android:id="@+id/toast_tv"
android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:maxLines="1"
+271
View File
@@ -0,0 +1,271 @@
# 餐盘柜设备端 API 文档
> 更新时间:2026-09-09 服务:platform-nutrition(端口 24810
## 一、通用约定
### 1. 路径与鉴权
- 所有接口路径前缀为 `/nutrition`,在 Nacos 白名单 `/nutrition/neglect/**` 下,**无需登录 token**
- 设备上下文统一靠请求头 **`X-DEVICE-CODE`** 解析(值为终端管理的设备编码),请求体无需传 deviceCode
### 2. 统一响应结构
```json
{ "code": "00000", "msg": "操作成功", "data": { }, "total": 0 }
```
| 字段 | 类型 | 说明 |
|------|------|------|
| code | string | `00000` 成功;`99999` 等为失败 |
| msg | string | 失败时直接展示给设备端(业务异常中文提示) |
| data | object/array | 业务数据 |
| total | int | 分页接口返回总条数,非分页为 0 |
### 3. 数据类型说明
- 所有 id(Long)序列化为**字符串**,防止精度丢失
- 金额为 BigDecimal,按原样输出(不转科学计数法)
- 时间格式 `yyyy-MM-dd HH:mm:ss`,日期 `yyyy-MM-dd`
### 4. 收费模式 chargeType
| 值 | 含义 | price |
|----|------|-------|
| 1 | 按餐计费 | 有值(元/份) |
| 2 | 称重计费 | null |
| 3 | 免费 | null |
---
## 二、接口明细
### 1. 获取设备绑定用户列表
`GET /nutrition/neglect/sideboard/app/getYxMemberRefPlateByEquipmentCode`
返回当前设备(按 X-DEVICE-CODE)全部格子,按格子序号升序。
**返回 data:数组**
| 字段 | 类型 | 说明 |
|------|------|------|
| id | string | 绑定记录id(解绑时使用) |
| equipmentId | string | 设备idnut_terminal.id |
| equipmentName | string | 设备名称 |
| equipmentCode | string | 设备编码 |
| equipmentBoxCode | string | 格子编号(1..N |
| faceId | string | 绑定用户id=nut_user.id),空格子为 null |
| plateNumber | string | 餐盘号,空格子为 null |
| orderNo | int | 格子排序号 |
| name | string | 用户姓名(未绑定为 null) |
| phone | string | 用户手机号(未绑定为 null) |
| faceUrl | string | 预留,人脸头像 |
| mealTime | string | 就餐时间(预留) |
| openTime | string | 最近开柜绑定时间 |
| updateTime | string | 更新时间 |
| eatCount | int | 预留 |
**示例**
```json
{
"code": "00000", "msg": "操作成功", "total": 0,
"data": [
{ "id": "1948000001", "equipmentId": "1001", "equipmentName": "1号餐盘柜",
"equipmentCode": "DEV-PLATE-CABINET-01", "equipmentBoxCode": "1",
"faceId": "10001", "plateNumber": "PLATE-001", "orderNo": 1,
"name": "张三", "phone": "138****0001", "faceUrl": null,
"mealTime": null, "openTime": "2026-09-04 11:20:00",
"updateTime": "2026-09-04 11:20:00", "eatCount": 0 }
]
}
```
---
### 2. 餐盘绑定
`POST /nutrition/neglect/sideboard/app/plateBinding`
**入参(JSON 请求体)**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| equipmentBoxCode | string | 是 | 目标格子编号 |
| faceId | long | 是 | 用户id(用户搜索接口返回的 faceId) |
| plateNumber | string | 是 | 餐盘号 |
| equipmentId / equipmentCode | - | 否 | 兼容旧项目的冗余字段,后端以请求头为准 |
```json
{ "equipmentBoxCode": "1", "faceId": 10001, "plateNumber": "PLATE-001" }
```
**处理规则**
1. 目标格子已绑定用户 → 报错「当前柜子已绑定用户」
2. 餐盘号已被其他用户绑定 → 报错「当前柜子已绑定用户」
3. 用户不存在或**不是会员**(is_vip≠1)→ 报错「用户查询失败」
4. 用户已绑定其他格子 → 自动释放旧格子并重建空格
5. 成功返回 `Result<Void>`,同时记录开柜时间
---
### 3. 餐盘解绑
`POST /nutrition/neglect/sideboard/app/plateUnbind`
> ⚠️ 与旧项目不同:参数在 **JSON 请求体**,不是 QueryString
**入参(JSON 请求体)**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | long | 是 | 绑定记录id(列表接口返回的 id) |
```json
{ "id": 1948000001 }
```
**错误**:记录不存在 → 「绑定记录不存在」。成功后原格子重建为空格。
---
### 4. 用户信息模糊搜索
`POST /nutrition/neglect/common/app/getUserInfoByNameOrPhone`
按姓名或手机号模糊搜索**会员**(仅 is_vip=1 且状态正常),不按食堂过滤。
**入参(JSON 请求体)**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| name | string | 否 | 姓名模糊 |
| phone | string | 否 | 手机号模糊(name/phone 至少一个) |
| pageNum | int | 否 | 默认 1 |
| pageSize | int | 否 | 默认 10,最大 500 |
**返回 data:数组**total 为总条数)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | string | 用户id |
| faceId | string | 同 id(键名对齐旧项目) |
| name | string | 姓名 |
| phone | string | 手机号 |
```json
{
"code": "00000", "msg": "操作成功", "total": 1,
"data": [ { "id": "10001", "faceId": "10001", "name": "张三", "phone": "13800000001" } ]
}
```
---
### 5. 通过餐盘号获取信息
`GET /nutrition/neglect/sideboard/app/getMemberRefPlateByPlateNumber?plateNumber=PLATE-001`
用户刷餐盘取餐时调用;**cardBalance 为真实账户余额**:余额 > 0 可开柜,负数提示用户充值。
**返回 data**
| 字段 | 类型 | 说明 |
|------|------|------|
| equipmentId | string | 设备id |
| equipmentName | string | 设备名称 |
| equipmentCode | string | 设备编码 |
| id | string | 绑定记录id |
| equipmentBoxCode | string | 格子编号 |
| updateTime | string | 更新时间 |
| plateNumber | string | 餐盘号 |
| faceId | string | 绑定用户id,未绑定为 null |
| name | string | 用户姓名,未绑定为 null |
| phone | string | 用户手机号,未绑定为 null |
| cardBalance | number | 账户真实余额(元);未绑定/无账户为 null |
**错误**:餐盘号为空 → 「餐盘号不能为空」;查无绑定 → 「未找到餐盘绑定信息」
```json
{
"code": "00000", "msg": "操作成功", "total": 0,
"data": {
"equipmentId": "1001", "equipmentName": "1号餐盘柜", "equipmentCode": "DEV-PLATE-CABINET-01",
"id": "1948000001", "equipmentBoxCode": "1", "updateTime": "2026-09-04 11:20:00",
"plateNumber": "PLATE-001", "faceId": "10001", "name": "张三", "phone": "13800000001",
"cardBalance": 25.50
}
}
```
---
### 6. 通过用户ID获取信息
`GET /nutrition/neglect/sideboard/app/getMemberRefPlateByUserId?userId=10001`
人脸识别匹配到 `userId` 后调用;查询**当前设备**上该用户绑定的餐盘/格子,**cardBalance 为真实账户余额**:余额 > 0 可开柜,负数提示用户充值。
**返回 data**(字段同「通过餐盘号获取信息」)
| 字段 | 类型 | 说明 |
|------|------|------|
| equipmentId | string | 设备id |
| equipmentName | string | 设备名称 |
| equipmentCode | string | 设备编码 |
| id | string | 绑定记录id |
| equipmentBoxCode | string | 格子编号 |
| updateTime | string | 更新时间 |
| plateNumber | string | 餐盘号 |
| faceId | string | 绑定用户id |
| name | string | 用户姓名 |
| phone | string | 用户手机号 |
| cardBalance | number | 账户真实余额(元) |
**错误**:用户ID为空 → 「用户ID不能为空」;查无绑定 → 「未找到用户绑定信息」
```json
{
"code": "00000", "msg": "操作成功", "total": 0,
"data": {
"equipmentId": "1001", "equipmentName": "1号餐盘柜", "equipmentCode": "DEV-PLATE-CABINET-01",
"id": "1948000001", "equipmentBoxCode": "1", "updateTime": "2026-09-04 11:20:00",
"plateNumber": "PLATE-001", "faceId": "10001", "name": "张三", "phone": "13800000001",
"cardBalance": 25.50
}
}
```
---
### 7. 获取扣费规则
`GET /nutrition/neglect/sideboard/app/getChargeRuleByEquipmentCode`
键名对齐旧项目 `getRegionRuleByEquipmentCode`。按「终端绑定餐线 → 今天星期 × 当前时段餐次」查询收费模式矩阵(nut_canteen_line_charge)。
**返回 data**
| 字段 | 类型 | 说明 |
|------|------|------|
| chargeType | int | 1按餐计费 / 2称重计费 / 3免费 |
| price | number | 仅 chargeType=1 时有值(元/份) |
**规则**
- 终端未绑定餐线 / 餐线停用 / 当日当餐未配置 → 默认返回 `chargeType=2`(称重计费)
- 餐次时段:早餐 06:00-10:00 / 午餐 10:00-14:00 / 加餐 14:00-16:00 / 晚餐 16:00-20:00(全局统一)
- **按餐计费的扣费由后端定时任务完成**:该餐次取餐结束(最后一条取餐记录 15 分钟后)统一按份扣余额,**硬件无需在取盘时扣款**,仅需按 cardBalance > 0 判断是否开柜
- 余额不足会扣成负数(后续充值回补),负余额即提示充值
```json
{ "code": "00000", "msg": "操作成功", "total": 0, "data": { "chargeType": 1, "price": 15.00 } }
```
---
## 三、错误响应示例
```json
{ "code": "99999", "msg": "当前柜子已绑定用户", "data": null, "total": 0 }
```
业务校验失败的 msg 为中文提示,可直接在设备端展示;参数校验失败(缺必填字段)返回 PARAM_ERROR。