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
48 changed files with 2822 additions and 420 deletions
+3
View File
@@ -100,4 +100,7 @@ dependencies {
implementation(libs.android.core)
implementation("org.greenrobot:eventbus:3.3.1")
// MQTT 客户端(人脸变更实时推送)
implementation(libs.paho.mqtt)
}
+4 -4
View File
@@ -47,10 +47,6 @@
android:exported="false"
android:launchMode="singleTask">
</activity>
<activity
android:name="com.sw.platecabinet.activity.LoginByPwdActivity"
android:exported="false"
android:launchMode="singleTask"/>
<activity
android:name="com.sw.platecabinet.activity.LoginByFaceActivity"
android:exported="true"
@@ -69,6 +65,10 @@
android:name="com.sw.platecabinet.activity.MainActivity"
android:exported="false">
</activity>
<activity
android:name="com.sw.platecabinet.activity.OpsActivity"
android:exported="false">
</activity>
</application>
</manifest>
@@ -4,6 +4,7 @@ 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
@@ -15,6 +16,8 @@ class MyApp : App() {
override fun onCreate() {
super.onCreate()
Timber.plant(Timber.DebugTree())
// 运行时日志落盘(供运维页查看/导出),与 DebugTree 并存
Timber.plant(FileLoggingTree(this))
Timber.d("初始化")
initGlobalData()
@@ -27,7 +30,8 @@ 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
@@ -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
@@ -399,6 +401,19 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
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) {
@@ -418,7 +433,7 @@ abstract class BaseActivity<VB : ViewBinding> : AppCompatActivity() {
pageNo = pageNo,
timestamp = timestamp
) {
recognizeViewModel.refreshFaceList()
scheduleFaceRefresh()
}
}
@@ -1,22 +1,12 @@
package com.sw.platecabinet.activity
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.view.WindowManager
import androidx.lifecycle.lifecycleScope
import com.sw.plate.utils.ToastUtils
import com.sw.plate.utils.arcface.facedb.FaceDatabase
import com.sw.platecabinet.Environment
import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.dialog.EnvironmentSelectDialog
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
/**
@@ -24,11 +14,6 @@ import timber.log.Timber
*/
class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
// 连点切换环境的计数与复位
private var tapCount = 0
private val tapHandler = Handler(Looper.getMainLooper())
private val resetTapRunnable = Runnable { tapCount = 0 }
override fun inflateViewBinding(): ActivityDeviceInitBinding {
return ActivityDeviceInitBinding.inflate(layoutInflater)
}
@@ -38,8 +23,7 @@ class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
}
override fun initialize() {
// 等待框设为不拦截触摸,确保连点手势可达
showWaitingDialog("加载中……")?.window?.addFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
showWaitingDialog("加载中……")
// 首次启动先全量拉取人脸缓存,后续走增量同步
if (SpTool.getFirstGetFace()) {
netViewModelV2.getUserFaceCache { status, msg ->
@@ -51,50 +35,6 @@ class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
}
}
/**
* 连续点击 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)
}
/**
* 弹出环境选择弹窗
*/
private fun showEnvironmentDialog() {
EnvironmentSelectDialog(this) { env ->
switchEnvironment(env)
}.show()
}
/**
* 切换环境:持久化 + 清空本地人脸库 + 重置增量同步状态
*/
private fun switchEnvironment(env: Environment) {
SpTool.setBaseUrl(env.url)
lifecycleScope.launch(Dispatchers.IO) {
val faceDao = FaceDatabase.getInstance(this@DeviceInitActivity).faceDao()
faceDao.deleteAll()
faceDao.resetId()
SpTool.setLastFaceTimestamp(0L)
SpTool.setFirstGetFace(true)
withContext(Dispatchers.Main) {
ToastUtils.showToast("已切换到${env.name},重启后生效")
}
}
}
/**
* 获取设备配置(V2),并初始化虹软 SDK 激活参数
*/
@@ -103,24 +43,24 @@ class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
runOnUiThread {
if (deviceConfig == null) {
hideWaitingDialog()
ToastUtils.showToast("获取设备配置失败,请连点屏幕3次切换环境")
// goLoginActivity()
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()
}
}, onFailure = { errMsg ->
runOnUiThread {
hideWaitingDialog()
Timber.e("getDeviceConfig onFailure: $errMsg")
ToastUtils.showToast("服务连接失败,请连点屏幕3次切换环境")
// goLoginActivity()
ToastUtils.showToast("服务连接失败,请到登录页连点 3 次切换环境")
goLoginActivity()
}
})
}
@@ -137,6 +77,9 @@ class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
}
private fun goLoginActivity() {
// 首次全量同步(如需)已完成,此时启动人脸 MQTT 实时订阅,
// 避免与首次全量同步的 clearFaceData 产生并发写竞态
FaceMqttSubscriber.start()
val intent = Intent(this, LoginByFaceActivity::class.java)
startActivity(intent)
finish()
@@ -35,12 +35,31 @@ 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
/**
* 人脸识别
@@ -49,6 +68,12 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
ViewTreeObserver.OnGlobalLayoutListener {
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,
@@ -86,11 +111,6 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
//开启人脸增量数据定时任务(V2
startFaceTask()
binding.llToPwd.setOnClickListener {
val intent = Intent(this, LoginByPwdActivity::class.java)
startActivity(intent)
}
// TODO: 测试请求
TaskManager.startTask()
@@ -120,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()
}
@@ -516,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.member.databinding.ActivityLoginByPwdBinding
import com.sw.platecabinet.member.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()
}
}
@@ -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))
}
}
}
@@ -5,6 +5,10 @@ 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
@@ -50,9 +54,8 @@ class EnvironmentSelectDialog(
ENVIRONMENTS.forEach { env ->
val rb = RadioButton(activity).apply {
id = View.generateViewId()
text = env.name
text = buildEnvironmentLabel(env.name, env.url)
gravity = Gravity.CENTER_VERTICAL
textSize = 18f
setTextColor(Color.WHITE)
buttonTintList = ColorStateList.valueOf(Color.WHITE)
isChecked = env.url == current
@@ -69,6 +72,19 @@ class EnvironmentSelectDialog(
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
}
/**
* 确认按钮:回调所选环境并关闭弹窗
*/
@@ -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()
}
})
// }
@@ -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
@@ -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()
}
@@ -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>
@@ -2,7 +2,6 @@ package com.sw.platecabinet.repository
import com.sw.platecabinet.model.DeviceConfig
import com.sw.platecabinet.model.request.BindParam
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.EquipmentUserInfo
@@ -71,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 ?: "") }
}
/**
@@ -95,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,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,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 ?: "自定义"
}
}
@@ -39,4 +39,15 @@ public class SpTool {
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
}
}
}
@@ -15,11 +15,13 @@ 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
@@ -30,7 +32,7 @@ import java.io.File
class NetViewModelV2 : ViewModel() {
companion object {
const val PAGE_SIZE = 100
const val PAGE_SIZE = 500
}
private val faceApi: FaceApi = FaceApi()
@@ -128,7 +130,7 @@ class NetViewModelV2 : ViewModel() {
GlobalData.activeKey,
object : FaceApi.ActiveCallback {
override fun onSuccess(activeCode: Int) {
Timber.d("activeEngine activeCode = $activeCode")
Timber.d("activeEngine activeCode = $activeCode=="+GlobalData.activeKey)
// ThreadUtils.launch {
viewModelScope.launch(Dispatchers.Main) {
when (activeCode) {
@@ -137,7 +139,7 @@ class NetViewModelV2 : ViewModel() {
}
ErrorInfo.MERR_ASF_ALREADY_ACTIVATED -> {
// ToastUtils.showToast("引擎已激活,无需再次激活")
ToastUtils.showToast("引擎已激活,无需再次激活")
}
else -> {
@@ -190,7 +192,8 @@ class NetViewModelV2 : ViewModel() {
}
withContext(Dispatchers.IO) {
val list = response.data ?: emptyList()
updateFaceData(list)
// 与 MQTT 实时更新串行化写库,避免同一 userFaceId 重复入库
FaceSyncLock.mutex.withLock { updateFaceData(list) }
// 取当前页最大时间戳,与已累积的比较取最大值
val pageMaxTimestamp = maxFaceTimestamp(list)
if (pageMaxTimestamp > faceTimestamp) {
@@ -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>
+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>
+1
View File
@@ -1,5 +1,6 @@
<resources>
<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>
+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" }
@@ -131,6 +131,37 @@ public class FaceApi {
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) {
@@ -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 {
@@ -125,4 +125,28 @@ public interface FaceDao {
*/
@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);
}
@@ -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;
}
}
+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。