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,统一接口调用参数格式
This commit is contained in:
mazengfei
2026-09-09 15:57:44 +08:00
parent 8bcc5a0289
commit 8fcbbb38f6
20 changed files with 407 additions and 295 deletions
-4
View File
@@ -47,10 +47,6 @@
android:exported="false" android:exported="false"
android:launchMode="singleTask"> android:launchMode="singleTask">
</activity> </activity>
<activity
android:name="com.sw.platecabinet.activity.LoginByPwdActivity"
android:exported="false"
android:launchMode="singleTask"/>
<activity <activity
android:name="com.sw.platecabinet.activity.LoginByFaceActivity" android:name="com.sw.platecabinet.activity.LoginByFaceActivity"
android:exported="true" android:exported="true"
@@ -31,6 +31,7 @@ class MyApp : App() {
private fun initGlobalData() { private fun initGlobalData() {
var deviceId = AppUtil.getUDID(this) 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") Log.d("MyApp", "initialize: deviceId=$deviceId")
GlobalData.deviceId = deviceId GlobalData.deviceId = deviceId
@@ -50,8 +50,8 @@ class DeviceInitActivity : BaseActivity<ActivityDeviceInitBinding>() {
GlobalData.appId = deviceConfig.arcsoftAppId ?: "" GlobalData.appId = deviceConfig.arcsoftAppId ?: ""
GlobalData.sdkKey = deviceConfig.arcsoftSdkKey ?: "" GlobalData.sdkKey = deviceConfig.arcsoftSdkKey ?: ""
// 测试设备后台下发的激活码不正确,手动硬编码覆盖为正确值(后台修复后可回退为下行) // 测试设备后台下发的激活码不正确,手动硬编码覆盖为正确值(后台修复后可回退为下行)
// GlobalData.activeKey = deviceConfig.arcsoftActiveKey ?: "" GlobalData.activeKey = deviceConfig.arcsoftActiveKey ?: ""
GlobalData.activeKey = "085F-118G-Q4V1-THBP" // GlobalData.activeKey = "085F-118G-Q4V1-THBP"
hideWaitingDialog() hideWaitingDialog()
goLoginActivity() goLoginActivity()
} }
@@ -111,11 +111,6 @@ class LoginByFaceActivity : BaseActivity<ActivityLoginFaceBinding>(),
//开启人脸增量数据定时任务(V2 //开启人脸增量数据定时任务(V2
startFaceTask() startFaceTask()
binding.llToPwd.setOnClickListener {
val intent = Intent(this, LoginByPwdActivity::class.java)
startActivity(intent)
}
// TODO: 测试请求 // TODO: 测试请求
TaskManager.startTask() TaskManager.startTask()
@@ -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()
}
}
@@ -283,8 +283,14 @@ class OpsActivity : BaseActivity<ActivityOpsBinding>() {
binding.btnExport.setOnClickListener { binding.btnExport.setOnClickListener {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val result = DiagnosticExporter.exportToUsb(this@OpsActivity) val result = DiagnosticExporter.export(this@OpsActivity)
withContext(Dispatchers.Main) { ToastUtils.showToast(result.message) } withContext(Dispatchers.Main) {
AlertDialog.Builder(this@OpsActivity)
.setTitle(if (result.success) "导出成功" else "导出失败")
.setMessage(result.message)
.setPositiveButton("确定", null)
.show()
}
} }
} }
@@ -262,7 +262,7 @@ class BindPlateFragment : BaseFragment<FragmentBindPlateBinding>(
//TODO 调用接口查询用户是否已绑盘 //TODO 调用接口查询用户是否已绑盘
this@BindPlateFragment.equipmentBoxCode = null this@BindPlateFragment.equipmentBoxCode = null
(activity as? MainActivity)?.showWaitingDialog("查询中,请稍后……") (activity as? MainActivity)?.showWaitingDialog("查询中,请稍后……")
userViewModel.getUserInfoById(memberId = item.faceId) { userViewModel.getUserInfoById(memberId = item.faceId, silent = true) {
(activity as? MainActivity)?.hideWaitingDialog() (activity as? MainActivity)?.hideWaitingDialog()
this@BindPlateFragment.equipmentBoxCode = it?.equipmentBoxCode this@BindPlateFragment.equipmentBoxCode = it?.equipmentBoxCode
} }
@@ -306,7 +306,10 @@ class BindPlateFragment : BaseFragment<FragmentBindPlateBinding>(
} }
override fun onFail(e: Exception?) { 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") // @SerializedName("memberFrom")
// val memberFrom: Int? = 0, // val memberFrom: Int? = 0,
// @SerializedName("pageNum") // @SerializedName("pageNum")
var pageNum: Int? = 0, var pageNum: Int? = 1,
// @SerializedName("pageSize") // @SerializedName("pageSize")
var pageSize: Int? = 0, var pageSize: Int? = 10,
// @SerializedName("param") // @SerializedName("param")
// val `param`: String? = "", // val `param`: String? = "",
var name:String?=null, var name:String?=null,
@@ -5,6 +5,7 @@ data class ApiResponse<T>(
// val success: Boolean? = false, // val success: Boolean? = false,
val msg: String? = "", val msg: String? = "",
val data: T? = null, val data: T? = null,
val total: Int = 0,
// val result: T? = null, // val result: T? = null,
) { ) {
// fun isSuccess(): Boolean = code == 200 // fun isSuccess(): Boolean = code == 200
@@ -12,9 +12,9 @@ import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
data class EquipmentUserInfo( data class EquipmentUserInfo(
/** /**
* 主键 * 主键(绑定记录id,后端序列化为字符串)
*/ */
val id: Long? = 0, val id: String? = "",
/** /**
* *
* 设备Id * 设备Id
@@ -4,7 +4,6 @@ import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.model.DeviceConfig import com.sw.platecabinet.model.DeviceConfig
import com.sw.platecabinet.model.request.BindParam import com.sw.platecabinet.model.request.BindParam
import com.sw.platecabinet.model.request.EquipmentParam 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.request.SearchParam
import com.sw.platecabinet.model.response.ApiResponse import com.sw.platecabinet.model.response.ApiResponse
import com.sw.platecabinet.model.response.EquipmentInfo 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.SearchResult
import com.sw.platecabinet.model.response.UserFaceModel import com.sw.platecabinet.model.response.UserFaceModel
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
import retrofit2.http.POST import retrofit2.http.POST
@@ -75,13 +72,13 @@ interface ApiService {
// @Body param: LoginParam // @Body param: LoginParam
// ): ApiResponse<EquipmentUserInfo> // ): ApiResponse<EquipmentUserInfo>
/** /**
* 餐盘用户信息获取 * 通过用户ID获取信息(人脸识别匹配到 userId 后调用,替代旧 getPlateBoxUserInfo 登录接口)
*/ */
@POST//("/shuwei-zhct/swEquipmentRelUser/equipmentBoxLogin") @GET
suspend fun equipmentBoxLogin( suspend fun getMemberRefPlateByUserId(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/sideboard/app/getPlateBoxUserInfo", @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/sideboard/app/getMemberRefPlateByUserId",
@Body param: LoginParam @Query("userId") userId: String
): ApiResponse<EquipmentUserInfo> ): ApiResponse<EquipmentUserInfo>
/** /**
@@ -95,7 +92,7 @@ interface ApiService {
@GET @GET
suspend fun getEquipmentList( 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>> ): ApiResponse<List<EquipmentUserInfo>>
// /** // /**
// * 餐盘用户信息绑定解绑 // * 餐盘用户信息绑定解绑
@@ -111,18 +108,17 @@ interface ApiService {
*/ */
@POST @POST
suspend fun plateBind( 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 @Body param: BindParam
): ApiResponse<EquipmentUserInfo?> ): ApiResponse<EquipmentUserInfo?>
/** /**
* 餐盘解绑 * 餐盘解绑JSON 请求体)
*/ */
@POST @POST
@FormUrlEncoded
suspend fun plateUnbind( suspend fun plateUnbind(
@Url url: String = "${GlobalData.appBaseUrl}/terminal/neglect/sideboard/app/plateUnbind", @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/sideboard/app/plateUnbind",
@Field("id") id: Long? @Body param: Map<String, String>
): ApiResponse<Any?> ): ApiResponse<Any?>
/** /**
@@ -131,7 +127,7 @@ interface ApiService {
@POST//("/shuwei-user/swclientUserInfoShop/selectList") @POST//("/shuwei-user/swclientUserInfoShop/selectList")
suspend fun searchUser( suspend fun searchUser(
// @Url url: String = "${GlobalData.appBaseUrl}/shuwei-user/swclientUserInfoShop/selectList", // @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 @Body param: SearchParam
): ApiResponse<List<SearchResult.Member>?> ): ApiResponse<List<SearchResult.Member>?>
@@ -150,7 +146,7 @@ interface ApiService {
*/ */
@GET @GET
suspend fun findByPlateNumber( 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 @Query("plateNumber") plateNumber: String
): ApiResponse<EquipmentUserInfo> ): ApiResponse<EquipmentUserInfo>
@@ -2,7 +2,6 @@ package com.sw.platecabinet.repository
import com.sw.platecabinet.model.DeviceConfig import com.sw.platecabinet.model.DeviceConfig
import com.sw.platecabinet.model.request.BindParam 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.request.SearchParam
import com.sw.platecabinet.model.response.ApiResponse import com.sw.platecabinet.model.response.ApiResponse
import com.sw.platecabinet.model.response.EquipmentUserInfo import com.sw.platecabinet.model.response.EquipmentUserInfo
@@ -71,10 +70,10 @@ class RemoteRepository constructor(
} }
/** /**
* 登录 * 通过用户ID获取信息(人脸识别匹配到 userId 后调用)
*/ */
suspend fun equipmentBoxLogin(param: LoginParam): ApiResponse<EquipmentUserInfo> { suspend fun getMemberRefPlateByUserId(userId: String?): ApiResponse<EquipmentUserInfo> {
return safeApiCall { apiService.equipmentBoxLogin(param = param) } return safeApiCall { apiService.getMemberRefPlateByUserId(userId = userId ?: "") }
} }
/** /**
@@ -95,8 +94,8 @@ class RemoteRepository constructor(
return safeApiCall { apiService.plateBind(param = param) } return safeApiCall { apiService.plateBind(param = param) }
} }
suspend fun plateUnbind(id: Long?): ApiResponse<Any?> { suspend fun plateUnbind(id: String?): ApiResponse<Any?> {
return safeApiCall { apiService.plateUnbind(id = id) } return safeApiCall { apiService.plateUnbind(param = mapOf("id" to (id ?: ""))) }
} }
/** /**
@@ -5,6 +5,7 @@ import com.sw.plate.utils.arcface.FaceApi
import com.sw.plate.utils.comn.SerialApi import com.sw.plate.utils.comn.SerialApi
import com.sw.platecabinet.GlobalData import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.mqtt.MqttManager import com.sw.platecabinet.mqtt.MqttManager
import timber.log.Timber
import java.io.BufferedOutputStream import java.io.BufferedOutputStream
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
@@ -15,8 +16,9 @@ import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
/** /**
* 诊断包导出:把设备信息、运行日志、崩溃日志打包成 zip,直接写入 U 盘 * 诊断包导出:把设备信息、运行日志、崩溃日志打包成 zip。
* 工控机现场无微信等分享应用,故不走 FileProvider 分享,改为写 U 盘 * 优先写 U 盘根目录;U 盘不可用或写入失败时兜底到应用 filesDir/diagnostic/
* 工控机现场无微信等分享应用,故不走 FileProvider 分享。
*/ */
object DiagnosticExporter { object DiagnosticExporter {
@@ -25,13 +27,27 @@ object DiagnosticExporter {
private val faceApi = FaceApi() private val faceApi = FaceApi()
/** 导出到 U 盘(在 IO 线程调用) */ /**
fun exportToUsb(context: Context): ExportResult { * 导出诊断包(在 IO 线程调用):
val usbDir = UsbStorageHelper.findUsbDir(context) * 1. 优先写 U 盘根目录;
?: return ExportResult(false, "未检测到可写 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 { return try {
val file = buildZip(context, usbDir) val file = buildZip(context, appDir)
ExportResult(true, "已导出: ${file.name}", file) ExportResult(true, "U 盘不可用,已导出到应用目录:\n${file.absolutePath}", file)
} catch (e: Exception) { } catch (e: Exception) {
ExportResult(false, "导出失败: ${e.message}") ExportResult(false, "导出失败: ${e.message}")
} }
@@ -41,7 +57,7 @@ object DiagnosticExporter {
private fun buildZip(context: Context, outDir: File): File { private fun buildZip(context: Context, outDir: File): File {
val time = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) val time = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val zipFile = File(outDir, "diagnostic_$time.zip") val zipFile = File(outDir, "diagnostic_$time.zip")
Timber.d("Building zip file: ${zipFile.absolutePath}")
ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos -> ZipOutputStream(BufferedOutputStream(FileOutputStream(zipFile))).use { zos ->
// 1. 设备快照 info.txt // 1. 设备快照 info.txt
zos.putNextEntry(ZipEntry("info.txt")) zos.putNextEntry(ZipEntry("info.txt"))
@@ -7,21 +7,65 @@ import java.io.File
/** /**
* U 盘定位:工控机现场无微信等分享应用,诊断包需直接写入 U 盘。 * U 盘定位:工控机现场无微信等分享应用,诊断包需直接写入 U 盘。
* 按可靠性依次尝试:StorageManager 可移动卷(API30+)→ /storage 卷目录 → /mnt 常见挂载点 → /proc/mounts。 *
* 关键教训:仅凭「vfat/exfat/ntfs」判断会误判——部分瑞芯微固件把 /oempriv 等
* OEM 私有分区也格式化成 vfat。因此必须「路径 + 文件系统」双重校验:
* 只有挂载点落在 /storage/ 下、或路径明显含 usb/udisk/sdcard 关键字,才算 U 盘;
* 根目录的 /oem /odm /vendor /system 等系统分区一律排除。
*/ */
object UsbStorageHelper { object UsbStorageHelper {
fun findUsbDir(context: Context): File? { fun findUsbDir(context: Context): File? {
api30Volume(context)?.let { return it } // 1. /storage 下卷标目录(XXXX-XXXX),Android 标准可移动存储位置
storageVolumeDir()?.let { return it } storageVolumeDir()?.let { return it }
commonMountPoints().forEach { if (isWritableDir(it)) return it } // 2. /proc/mounts 里"明显是外接存储"的可移动介质挂载点
procMountsDir()?.let { return it } 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 return null
} }
private fun isWritableDir(dir: File): Boolean = dir.exists() && dir.isDirectory && dir.canWrite() private fun isWritableDir(dir: File): Boolean = dir.exists() && dir.isDirectory && dir.canWrite()
/** API 30+ 通过 StorageManager 拿可移动卷目录(最可靠) */ /** 挂载点是否"明显是外接存储":排除根目录系统分区 */
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? { private fun api30Volume(context: Context): File? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
return try { return try {
@@ -32,27 +76,28 @@ object UsbStorageHelper {
} }
} }
/** /storage 下非 emulated/self 的目录(U 盘卷形如 /storage/ABCD-1234 */ /** /storage 下卷标目录(U 盘卷形如 /storage/ABCD-1234 */
private fun storageVolumeDir(): File? { private fun storageVolumeDir(): File? {
return try { return try {
File("/storage").listFiles() File("/storage").listFiles()
?.filter { it.isDirectory && it.name != "emulated" && it.name != "self" && it.canWrite() } ?.filter { it.isDirectory && it.name != "emulated" && it.name != "self" && it.canWrite() }
?.firstOrNull() ?.firstOrNull { isRemovableFs(it) }
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
} }
/** 常见挂载点(不同 Android 版本/固件差异大,做兜底 */ /** 常见挂载点(路径本身即含 usb/udisk/sdcard 关键字 */
private fun commonMountPoints(): List<File> = listOf( private fun commonMountPoints(): List<File> = listOf(
File("/mnt/usb_storage"), File("/mnt/usb_storage"),
File("/mnt/usb"), File("/mnt/usb"),
File("/mnt/udisk"),
File("/storage/usb"), File("/storage/usb"),
File("/mnt/external_sd"), File("/mnt/external_sd"),
File("/mnt/sdcard2") File("/mnt/sdcard2")
) )
/** 读 /proc/mounts 找可移动介质挂载点vfat/exfat/ntfs/fuseblk */ /** 读 /proc/mounts 找"明显是外接存储"的可移动介质挂载点 */
private fun procMountsDir(): File? { private fun procMountsDir(): File? {
return try { return try {
File("/proc/mounts").readLines() File("/proc/mounts").readLines()
@@ -61,9 +106,7 @@ object UsbStorageHelper {
if (parts.size < 3) return@mapNotNull null if (parts.size < 3) return@mapNotNull null
val fsType = parts[2] val fsType = parts[2]
val mountPoint = parts[1].replace("\\040", " ") val mountPoint = parts[1].replace("\\040", " ")
if (fsType.contains("vfat") || fsType.contains("exfat") || if (isRemovableFsType(fsType) && isUsbMountPoint(mountPoint)) File(mountPoint) else null
fsType.contains("ntfs") || fsType.contains("fuseblk")
) File(mountPoint) else null
} }
.firstOrNull { isWritableDir(it) } .firstOrNull { isWritableDir(it) }
} catch (e: Exception) { } catch (e: Exception) {
@@ -130,7 +130,7 @@ class NetViewModelV2 : ViewModel() {
GlobalData.activeKey, GlobalData.activeKey,
object : FaceApi.ActiveCallback { object : FaceApi.ActiveCallback {
override fun onSuccess(activeCode: Int) { override fun onSuccess(activeCode: Int) {
Timber.d("activeEngine activeCode = $activeCode") Timber.d("activeEngine activeCode = $activeCode=="+GlobalData.activeKey)
// ThreadUtils.launch { // ThreadUtils.launch {
viewModelScope.launch(Dispatchers.Main) { viewModelScope.launch(Dispatchers.Main) {
when (activeCode) { when (activeCode) {
@@ -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()) block(null to ErrorInfo())
launchWithLoading { launchWithLoading {
val response = repository.plateUnbind(id) val response = repository.plateUnbind(id)
@@ -12,7 +12,6 @@ import com.sw.platecabinet.GlobalData
import com.sw.platecabinet.GlobalData.globalEquipmentCode import com.sw.platecabinet.GlobalData.globalEquipmentCode
import com.sw.platecabinet.GlobalKey import com.sw.platecabinet.GlobalKey
import com.sw.platecabinet.model.DeviceConfig 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.EquipmentUserInfo
import com.sw.platecabinet.model.response.UserFaceModel import com.sw.platecabinet.model.response.UserFaceModel
import com.sw.platecabinet.utils.SpTool import com.sw.platecabinet.utils.SpTool
@@ -93,29 +92,29 @@ class UserViewModel : BaseViewModel() {
private var faceTimestamp = 0L private var faceTimestamp = 0L
/**
* 校验码登录
*/
fun loginWithPwd(loginParam: LoginParam) {
launchWithLoading {
val response = repository.equipmentBoxLogin(loginParam)
if (parseResponse(response)) {
_currentUserInfo.value = response.data
}
}
}
/** /**
* 通过用户id获取用户信息 * 通过用户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 _currentUserInfo.value = null
launchWithLoading { launchWithLoading {
val loginParam = LoginParam(faceId = memberId) val response = repository.getMemberRefPlateByUserId(userId = memberId)
val response = repository.equipmentBoxLogin(loginParam) if (response.isSuccess()) {
if (parseResponse(response)) {
_currentUserInfo.value = response.data _currentUserInfo.value = response.data
action(response.data) action(response.data)
} else {
if (!silent) {
Timber.d("msg = ${response.msg}, code = ${response.code}")
ToastUtils.showToast("${response.msg}(${response.code})")
}
action(null)
} }
} }
} }
@@ -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>
+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。