From 737baf848613cf64d9c681b744ac72fd6f68219c Mon Sep 17 00:00:00 2001 From: lvmeng <848755140@qq.com> Date: Thu, 28 May 2026 19:57:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E6=9B=B4=E6=96=B0API=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=E5=B9=B6=E9=9B=86=E6=88=90=E4=BA=BA=E8=84=B8=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E5=AF=BC=E5=85=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将所有API端点中的/booth路径替换为/serve - 新增searchFood API用于菜品搜索功能 - 新增getCollectVectorPage API用于向量数据分页获取 - 添加FaceDbImporter工具类实现从assets导入人脸数据 - 在初始化活动中集成人脸数据导入功能 - 更新订单创建逻辑适配新的API参数结构 - 重构菜品搜索和订单处理相关API调用 - 添加V2数据模型到V1模型的转换扩展函数 - 更新绑定订单接口以使用新的API端点 - 添加测试模式支持以跳过实际拍照操作 --- .../sw/dualscreen/activity/InitActivity.kt | 44 +++ .../sw/dualscreen/activity/MainActivity.kt | 100 +++--- .../com/sw/dualscreen/activity/PayActivity.kt | 14 +- .../model/response/v2/NewFoodInfo.kt | 21 ++ .../model/response/v2/NewMemberInfo.kt | 14 + .../model/response/v2/SettlementOrder.kt | 22 ++ .../sw/dualscreen/network/api/ApiServiceV2.kt | 40 ++- .../repository/v2/RemoteRepositoryV2.kt | 30 ++ .../sw/dualscreen/utils/FaceDbImporter.java | 168 ++++++++++ .../com/sw/dualscreen/utils/FoodVectorTool.kt | 4 +- .../sw/dualscreen/utils/PhotoCaptureHelper.kt | 28 ++ .../sw/dualscreen/viewmodel/NetViewModelV2.kt | 293 ++++++------------ 12 files changed, 517 insertions(+), 261 deletions(-) create mode 100644 app/src/main/java/com/sw/dualscreen/utils/FaceDbImporter.java diff --git a/app/src/main/java/com/sw/dualscreen/activity/InitActivity.kt b/app/src/main/java/com/sw/dualscreen/activity/InitActivity.kt index 2626056..21207eb 100644 --- a/app/src/main/java/com/sw/dualscreen/activity/InitActivity.kt +++ b/app/src/main/java/com/sw/dualscreen/activity/InitActivity.kt @@ -14,6 +14,7 @@ import com.sw.dualscreen.ext.clickWithDebounce import com.sw.dualscreen.ext.invisible import com.sw.dualscreen.ext.visible import com.sw.dualscreen.objbox.FoodModule +import com.sw.dualscreen.utils.FaceDbImporter import com.sw.dualscreen.utils.FoodVectorTool import com.sw.dualscreen.utils.L import com.sw.dualscreen.utils.NetworkUtils @@ -22,7 +23,10 @@ import com.sw.dualscreen.viewmodel.BaseViewModel import com.sw.dualscreen.viewmodel.NetViewModelV2 import com.sw.plate.utils.AppUtil import com.sw.plate.utils.ToastUtils +import com.sw.plate.utils.arcface.faceserver.FaceServer +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class InitActivity : BaseActivity() { companion object { @@ -80,6 +84,13 @@ class InitActivity : BaseActivity() { // binding.initButton.setOnClickListener { // viewModel.getDeviceToken() // } + + lifecycleScope.launch { + withContext(Dispatchers.IO) { + FaceServer.getInstance().clearAllFaces() + loadTestDb() + } + } } override fun registerDataChange() { @@ -189,6 +200,10 @@ class InitActivity : BaseActivity() { } } + var arcsoftAppId = "Hkz1rBk6PZXbS8KwKr67K2eZtsz8bRoMHLg64bUdgCZj" + var arcsoftSdkKey = "AabXs3sHM8UhhE7oCGf4LVMLrdkGb1nJNksbjjTdVt7k" + var arcsoftActiveKey = "085F-118G-Q4GH-B2YH" + private fun getDeviceConfig() { userViewModel.getDeviceConfig( onSuccess = { deviceConfig -> @@ -200,6 +215,12 @@ class InitActivity : BaseActivity() { val settlementMode = if (deviceConfig.payType == 2) 0 else 1 SpTool.settlementMode = settlementMode + // TODO: 临时写死用于测试---------------------------------------- + deviceConfig.arcsoftAppId = arcsoftAppId + deviceConfig.arcsoftSdkKey = arcsoftSdkKey + deviceConfig.arcsoftActiveKey = arcsoftActiveKey + // TODO: 临时写死用于测试---------------------------------------- + GlobalData.appId = deviceConfig.arcsoftAppId ?: "" GlobalData.sdkKey = deviceConfig.arcsoftSdkKey ?: "" GlobalData.activeKey = deviceConfig.arcsoftActiveKey ?: "" @@ -240,4 +261,27 @@ class InitActivity : BaseActivity() { checkNetworkTimer?.cancel() super.onDestroy() } + + + + private fun loadTestDb() { + // 从测试库导入人脸数据 + FaceDbImporter.importFromAssets(this, object : FaceDbImporter.ImportCallback { + public override fun onProgress(current: Int, total: Int) { + showWaitingDialog("导入人脸数据 " + current + "/" + total) + } + + public override fun onError(message: String?) { + hideWaitingDialog() + ToastUtils.showToast("离线数据导入失败:" + message) + } + + public override fun onComplete() { + hideWaitingDialog() + ToastUtils.showToast("离线数据导入成功") + // 后续流程会自动跳转 + } + }) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/sw/dualscreen/activity/MainActivity.kt b/app/src/main/java/com/sw/dualscreen/activity/MainActivity.kt index ee19271..42e4ee3 100644 --- a/app/src/main/java/com/sw/dualscreen/activity/MainActivity.kt +++ b/app/src/main/java/com/sw/dualscreen/activity/MainActivity.kt @@ -44,11 +44,13 @@ import com.sw.dualscreen.ext.load import com.sw.dualscreen.ext.visible import com.sw.dualscreen.model.response.ChargeModeEvent import com.sw.dualscreen.model.response.FoodInfo -import com.sw.dualscreen.model.response.FoodOrder +import com.sw.dualscreen.model.request.v2.PlaceOrderRequest import com.sw.dualscreen.model.response.PaySuccessEvent import com.sw.dualscreen.model.response.ResetRecognizeEvent import com.sw.dualscreen.model.response.UpdateRefreshEvent import com.sw.dualscreen.model.response.v2.FaceVO +import com.sw.dualscreen.model.response.v2.NewFoodInfo +import com.sw.dualscreen.model.response.v2.toFoodInfo import com.sw.dualscreen.objbox.Food import com.sw.dualscreen.objbox.FoodModule import com.sw.dualscreen.objbox.FoodModule.IdNameScore @@ -134,6 +136,7 @@ class MainActivity : BaseActivity() { } } var isSwitchFood = false + /** * 用于切换菜品时重置数据 */ @@ -145,11 +148,11 @@ class MainActivity : BaseActivity() { //联合支付,切换菜品重新请求接口 presentation?.let { val userId = presentation?.currentUserId - if(userId.isNullOrBlank()) { + if (userId.isNullOrBlank()) { //切换菜品时,用户已离开重新打开识别功能 it.pauseCamera() it.step2FaceRecognizing(foodInfo, true) - } else{ + } else { //切换菜品时,用户未离开,重新请求接口 it.step3ShowRecognizeResult(userId) } @@ -374,7 +377,9 @@ class MainActivity : BaseActivity() { log("registerDataChange weight 联合支付-余量计量") if (weight <= WEIGHT_RESET_RECOGNIZE) { log("registerDataChange weight 联合支付-余量计量,重量小于${WEIGHT_RESET_RECOGNIZE}g,step1FoodRecognizing") - if (presentation?.currentUserId.isNullOrBlank().not() && presentation!!.jointPaymentQueryFinish) { + if (presentation?.currentUserId.isNullOrBlank() + .not() && presentation!!.jointPaymentQueryFinish + ) { //联合支付+余量计量,已识别人脸的状态下,从秤上拿餐品后,不再读取数据,等待人脸离开提交订单 if (lastWeight != weight) { presentation?.updateWeight(weight) @@ -430,7 +435,7 @@ class MainActivity : BaseActivity() { val mealPickupMode = SPUtil.getInstance().get(GlobalKey.KEY_PICKUP_MODE, 0) ?: 0 //0-计费,1-不计费 val chargeMode = SPUtil.getInstance().get(GlobalKey.KEY_CHARGE_MODE, 0) - if(mealPickupMode == 0 && chargeMode == 1) { + if (mealPickupMode == 0 && chargeMode == 1) { //即放即取+不计费模式, if (presentation?.currentUserId.isNullOrBlank().not()) { //已识别人脸的状态下,从秤上拿餐品后,不再读取数据,等待人脸离开提交订单 @@ -571,7 +576,7 @@ class MainActivity : BaseActivity() { } foodRecognizeState = true //查询到菜品信息,同步设置lastWeight=currentWeight - lastWeight=currentWeight + lastWeight = currentWeight presentation?.updateWeight(currentWeight) list.forEach { foodInfo -> @@ -892,63 +897,59 @@ class MainActivity : BaseActivity() { val chargeMode = SPUtil.getInstance().get(GlobalKey.KEY_CHARGE_MODE, 0) //0-即放即取,1-余量计量 val pickupMode = SPUtil.getInstance().get(GlobalKey.KEY_PICKUP_MODE, 0) ?: 0 -// if (pickupMode == 0) { -// if (foodWeight <= 5) { -// ToastUtils.showToast("餐品重量不足") -// return -// } -// if (eatWeight <= 5) { -// ToastUtils.showToast("取餐重量过小") -// return -// } -// } if (eatNum <= 0) { ToastUtils.showToast("您好,当前重量不足一份") notEnoughOneBlock() return } showWaitingDialog("加载中,请稍候……") - val order = FoodOrder( + val request = PlaceOrderRequest( deviceId = GlobalData.deviceId, - foodId = foodInfo.foodId, + foodId = foodInfo.foodId.toLong(), foodName = foodInfo.foodName ?: "", - foodMaterialId = foodInfo.foodMaterialId ?: "", - specId = foodInfo.specId ?: "", - foodWeight = foodWeight, - eatWeight = eatWeight, - //根据specId对应规格重量计算 + foodMaterialId = foodInfo.foodMaterialId?.toLongOrNull(), + specId = foodInfo.specId?.toLongOrNull(), + foodWeight = foodWeight.toBigDecimal(), + eatWeight = eatWeight.toBigDecimal(), eatNum = eatNum, - userId = userId, + userId = userId?.toLongOrNull(), notPay = chargeMode != 0, //即放即取-1,称重-2 mode = if (pickupMode == 1) 1 else 2, paymentFrom = if (settlementMode == 0) 1 else 2, member = isMember ) - submitOrder(order, successBlock) + submitOrder(request, successBlock) } - private fun submitOrder(order: FoodOrder, successBlock: () -> Unit) { - viewModel.createOrder(order) { orderId -> - runOnUiThread { - binding.root.postDelayed({ - hideWaitingDialog() - if (orderId.isBlank()) { - //ToastUtils.showToast("订单id为空") - return@postDelayed - } - foodOrderId = orderId - successBlock() - - if (checkedItem?.isFromSearch == true) { - //当前菜名为手动搜索选择,非识别结果,保存向量数据 - lifecycleScope.launch { - saveFoodVector(checkedItem!!) + private fun submitOrder(request: PlaceOrderRequest, successBlock: () -> Unit) { + viewModel.placeOrder( + orderRequest = request, + onSuccess = { orderId -> + runOnUiThread { + binding.root.postDelayed({ + hideWaitingDialog() + if (orderId.isNullOrBlank()) { + return@postDelayed } - } - }, 300) + foodOrderId = orderId + successBlock() + + if (checkedItem?.isFromSearch == true) { + lifecycleScope.launch { + saveFoodVector(checkedItem!!) + } + } + }, 300) + } + }, + onFailure = { errorMsg -> + runOnUiThread { + hideWaitingDialog() + ToastUtils.showToast(errorMsg) + } } - } + ) } fun getEatNum(realWeight: Double?, specWeight: Double?): Int { @@ -1147,7 +1148,7 @@ class MainActivity : BaseActivity() { ObjectBox.put( Food( collectId = idList[0], - foodId = "$foodId", + foodId = foodId.toString(), foodName = foodName, foodVector = imageVector, version = foodModelVersion @@ -1325,14 +1326,17 @@ class MainActivity : BaseActivity() { } else { //binding.layoutRescan.visibility = View.GONE showWaitingDialog("正在查询菜品信息,请稍后……") - viewModel.getFoodInfo(foodName) { list -> + viewModel.getFoodByNames(names = foodName, onSuccess = { list -> runOnUiThread { hideWaitingDialog() log("registerDataChange main,getFoodInfo耗时:${System.currentTimeMillis() - startTime}") startTime = System.currentTimeMillis() - updateFoodInfo(list.toMutableList(), scoreList) + updateFoodInfo(list.map { it.toFoodInfo() }.toMutableList(), scoreList) } - } + }, onFailure = { errorMsg -> + hideWaitingDialog() + ToastUtils.showToast(errorMsg) + }) } } diff --git a/app/src/main/java/com/sw/dualscreen/activity/PayActivity.kt b/app/src/main/java/com/sw/dualscreen/activity/PayActivity.kt index 5ba4fce..ad8b645 100644 --- a/app/src/main/java/com/sw/dualscreen/activity/PayActivity.kt +++ b/app/src/main/java/com/sw/dualscreen/activity/PayActivity.kt @@ -302,11 +302,17 @@ class PayActivity : BaseActivity() { //0-即放即取,1-余量计量 val pickupMode = SPUtil.getInstance().get(GlobalKey.KEY_PICKUP_MODE, 0) val mode = if (pickupMode == 1) 1 else 2 - userViewModel.bindOrder(userId, foodOrderId, mode = mode) { bindResult -> - runOnUiThread { - block(bindResult) - } + val uid = userId.toLongOrNull() ?: run { + runOnUiThread { block(false) } + return } + userViewModel.bindUserOrder( + userId = uid, + orderNo = foodOrderId, + mode = mode, + onSuccess = { runOnUiThread { block(true) } }, + onFailure = { runOnUiThread { block(false) } } + ) } fun qrCodePay(scanInfo: String) { diff --git a/app/src/main/java/com/sw/dualscreen/model/response/v2/NewFoodInfo.kt b/app/src/main/java/com/sw/dualscreen/model/response/v2/NewFoodInfo.kt index 22e8302..08a4d1f 100644 --- a/app/src/main/java/com/sw/dualscreen/model/response/v2/NewFoodInfo.kt +++ b/app/src/main/java/com/sw/dualscreen/model/response/v2/NewFoodInfo.kt @@ -1,6 +1,7 @@ package com.sw.dualscreen.model.response.v2 import android.os.Parcelable +import com.sw.dualscreen.model.response.FoodInfo import kotlinx.parcelize.Parcelize import java.math.BigDecimal @@ -31,3 +32,23 @@ data class NewFoodInfo( val tablewareStatus: Boolean = false, val tablewareWeight: Int = 0 ) : Parcelable + +/** NewFoodInfo → FoodInfo 映射(V2 数据模型兼容旧版 UI) */ +fun NewFoodInfo.toFoodInfo() = FoodInfo( + foodId = foodId.toString(), + foodName = foodName, + calorie = calorie?.toDouble(), + protein = protein?.toDouble(), + fat = fat?.toDouble(), + carbohydrate = carbohydrate?.toDouble(), + specPrice = specPrice?.toDouble(), + vipPrice = vipPrice?.toDouble(), + foodMaterialId = foodMaterialId?.toString(), + specId = specId?.toString(), + specWeight = specWeight?.toDouble(), + stapleFood = stapleFood?.toDouble(), + fruitsVegetables = fruitsVegetables?.toDouble(), + meatEggs = meatEggs?.toDouble(), + recommendCalorie = recommendCalorie.toDouble(), + foodImg = foodImg +) diff --git a/app/src/main/java/com/sw/dualscreen/model/response/v2/NewMemberInfo.kt b/app/src/main/java/com/sw/dualscreen/model/response/v2/NewMemberInfo.kt index f094b7d..f89a2b1 100644 --- a/app/src/main/java/com/sw/dualscreen/model/response/v2/NewMemberInfo.kt +++ b/app/src/main/java/com/sw/dualscreen/model/response/v2/NewMemberInfo.kt @@ -2,6 +2,7 @@ package com.sw.dualscreen.model.response.v2 import android.os.Parcelable import kotlinx.parcelize.Parcelize +import com.sw.dualscreen.model.response.MemberInfo import java.math.BigDecimal /** @@ -19,3 +20,16 @@ data class NewMemberInfo( val integralBalance: Int?, val member: Boolean? ) : Parcelable + +/** NewMemberInfo → MemberInfo 映射(V2 数据模型兼容旧版 UI,faceUserId 复用 id) */ +fun NewMemberInfo.toMemberInfo() = MemberInfo( + id = id, + faceUserId = id, + phone = phone, + name = name, + faceUrl = faceUrl, + topUpBalance = topUpBalance?.toDouble(), + rewardBalance = rewardBalance?.toDouble(), + integralBalance = integralBalance, + member = member ?: false +) diff --git a/app/src/main/java/com/sw/dualscreen/model/response/v2/SettlementOrder.kt b/app/src/main/java/com/sw/dualscreen/model/response/v2/SettlementOrder.kt index a60e89f..e0a2ff8 100644 --- a/app/src/main/java/com/sw/dualscreen/model/response/v2/SettlementOrder.kt +++ b/app/src/main/java/com/sw/dualscreen/model/response/v2/SettlementOrder.kt @@ -1,6 +1,8 @@ package com.sw.dualscreen.model.response.v2 import android.os.Parcelable +import com.sw.dualscreen.model.response.FoodItem +import com.sw.dualscreen.model.response.FoodOrderModel import kotlinx.parcelize.Parcelize import java.math.BigDecimal @@ -29,3 +31,23 @@ data class SettlementFoodItem( val eatWeight: Int?, val price: BigDecimal? ) : Parcelable + +/** SettlementOrder → FoodOrderModel 映射(V2 数据模型兼容旧版 UI) */ +fun SettlementOrder.toFoodOrderModel() = FoodOrderModel( + calorie = calorie?.toDouble() ?: 0.0, + incomeSum = incomeSum?.toDouble() ?: 0.0, + discountSum = discountSum?.toDouble() ?: 0.0, + eatWeightSum = eatWeightSum ?: 0, + orderNo = orderNo, + list = list?.map { it.toFoodItem() } +) + +private fun SettlementFoodItem.toFoodItem() = FoodItem( + foodId = foodId, + foodName = foodName, + specId = specId, + specName = specName, + specWeight = specWeight?.toInt() ?: 0, + eatNum = eatNum ?: 0, + price = price?.toDouble() ?: 0.0 +) diff --git a/app/src/main/java/com/sw/dualscreen/network/api/ApiServiceV2.kt b/app/src/main/java/com/sw/dualscreen/network/api/ApiServiceV2.kt index bcd27a3..7fdc538 100644 --- a/app/src/main/java/com/sw/dualscreen/network/api/ApiServiceV2.kt +++ b/app/src/main/java/com/sw/dualscreen/network/api/ApiServiceV2.kt @@ -31,86 +31,98 @@ interface ApiServiceV2 { @GET suspend fun getDeviceConfig( - @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/booth/device/config" + @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/serve/device/config" ): ApiResponse @POST suspend fun getFacePage( - @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/booth/face/page", + @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/serve/face/page", @Body request: Map ): ApiResponse?> @POST suspend fun getFaceIncrement( - @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/booth/face/increment", + @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/serve/face/increment", @Body request: Map ): ApiResponse?> @POST suspend fun getFoodByNames( - @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/booth/food/by-names", + @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/serve/food/by-names", @Body request: FoodSearchReq ): ApiResponse?> + @POST + suspend fun searchFood( + @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/serve/food/search", + @Body request: Map + ): ApiResponse?> + @POST suspend fun getUserCurrentFood( - @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/booth/user/current-food", + @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/serve/user/current-food", @Body request: Map ): ApiResponse @POST suspend fun placeOrder( - @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/booth/order/place", + @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/serve/order/place", @Body request: PlaceOrderRequest ): ApiResponse @POST suspend fun getSettlementOrders( - @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/booth/order/settlement", + @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/serve/order/settlement", @Body request: Map ): ApiResponse @POST suspend fun getMemberInfo( - @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/booth/member/info", + @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/serve/member/info", @Body request: Map ): ApiResponse @POST suspend fun getMemberInfoByPhone( - @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/booth/member/info-by-phone", + @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/serve/member/info-by-phone", @Body request: Map ): ApiResponse @POST suspend fun getMemberDiscount( - @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/booth/member/discount", + @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/serve/member/discount", @Body request: Map ): ApiResponse @POST suspend fun bindUserOrder( - @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/booth/order/bind-user", + @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/serve/order/bind-user", @Body request: BindUserOrderRequest ): ApiResponse @POST suspend fun getCollectPage( - @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/booth/collect/page", + @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/serve/collect/page", @Body request: Map ): ApiResponse?> + @POST + suspend fun getCollectVectorPage( + @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/serve/collect/vector-page", + @Body request: Map + ): ApiResponse?> + @Multipart @POST suspend fun uploadCollect( - @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/booth/collect/upload", + @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/serve/collect/upload", @PartMap params: HashMap, @Part foodPics: List ): ApiResponse?> @POST suspend fun deleteCollect( - @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/booth/collect/delete", + @Url url: String = "${GlobalData.appBaseUrl}/nutrition/neglect/serve/collect/delete", @Body request: Map ): ApiResponse } diff --git a/app/src/main/java/com/sw/dualscreen/repository/v2/RemoteRepositoryV2.kt b/app/src/main/java/com/sw/dualscreen/repository/v2/RemoteRepositoryV2.kt index 8295b73..a6b1acf 100644 --- a/app/src/main/java/com/sw/dualscreen/repository/v2/RemoteRepositoryV2.kt +++ b/app/src/main/java/com/sw/dualscreen/repository/v2/RemoteRepositoryV2.kt @@ -71,6 +71,22 @@ class RemoteRepositoryV2 constructor( } } + suspend fun searchFood( + name: String, + pageNum: Long = 1L, + pageSize: Long = 100L + ): ApiResponse?> { + return safeApiCall { + apiService.searchFood( + request = mapOf( + "name" to name, + "pageNum" to pageNum, + "pageSize" to pageSize + ) + ) + } + } + suspend fun getUserCurrentFood( userId: Long ): ApiResponse { @@ -162,6 +178,20 @@ class RemoteRepositoryV2 constructor( } } + suspend fun getCollectVectorPage( + pageNum: Long = 1L, + pageSize: Long = 100L + ): ApiResponse?> { + return safeApiCall { + apiService.getCollectVectorPage( + request = mutableMapOf( + "pageNum" to pageNum, + "pageSize" to pageSize + ) + ) + } + } + suspend fun uploadCollect( foodId: Long, foodName: String, diff --git a/app/src/main/java/com/sw/dualscreen/utils/FaceDbImporter.java b/app/src/main/java/com/sw/dualscreen/utils/FaceDbImporter.java new file mode 100644 index 0000000..926c3c3 --- /dev/null +++ b/app/src/main/java/com/sw/dualscreen/utils/FaceDbImporter.java @@ -0,0 +1,168 @@ +package com.sw.dualscreen.utils; + +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.Handler; +import android.os.Looper; + +import com.sw.plate.utils.arcface.facedb.FaceDatabase; +import com.sw.plate.utils.arcface.facedb.entity.FaceEntity; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +/** + * 从 assets 目录下的测试人脸数据库导入数据到 Room 数据库 + * + *

以分批方式读取,每批 {@link #BATCH_SIZE} 条,边读边写, + * 避免 4350 条 + 17MB 特征数据一次性加载到内存。

+ */ +public class FaceDbImporter { + + private static final String TAG = "FaceDbImporter"; + + /** 每批写入条数 */ + private static final int BATCH_SIZE = 200; + + /** assets 中测试数据库的相对路径 */ + private static final String ASSETS_DB_PATH = "db/faceDB.db"; + + /** + * 导入回调 + */ + public interface ImportCallback { + /** 进度更新,已切到主线程 */ + void onProgress(int current, int total); + /** 导入失败,已切到主线程 */ + void onError(String message); + /** 导入完成,已切到主线程 */ + void onComplete(); + } + + private FaceDbImporter() { + } + + /** + * 从 assets 导入测试人脸数据到 Room 数据库 + * + * @param context 上下文 + * @param callback 回调(所有回调均已在主线程) + */ + public static void importFromAssets(Context context, ImportCallback callback) { + Context appContext = context.getApplicationContext(); + new Thread(() -> { + File tempFile = null; + SQLiteDatabase testDb = null; + Cursor cursor = null; + + try { + // 1. 从 assets 复制到缓存目录(SQLiteDatabase 需要文件路径) + tempFile = new File(appContext.getCacheDir(), "faceDB_import_temp.db"); + copyAssetToFile(appContext, ASSETS_DB_PATH, tempFile); + + // 2. 以只读方式打开测试数据库 + testDb = SQLiteDatabase.openDatabase( + tempFile.getAbsolutePath(), + null, + SQLiteDatabase.OPEN_READONLY + ); + + // 3. 查询总记录数 + int totalCount = 0; + Cursor countCursor = testDb.rawQuery("SELECT COUNT(*) FROM face", null); + if (countCursor.moveToFirst()) { + totalCount = countCursor.getInt(0); + } + countCursor.close(); + + if (totalCount == 0) { + postToMain(() -> { + if (callback != null) callback.onComplete(); + }); + return; + } + + // 4. 分批读取 + 批量写入 + cursor = testDb.rawQuery( + "SELECT user_name, feature_data, register_time FROM face ORDER BY faceId", + null + ); + + List batch = new ArrayList<>(BATCH_SIZE); + int processedCount = 0; + + while (cursor.moveToNext()) { + String userName = cursor.getString(0); + byte[] featureData = cursor.getBlob(1); + long registerTime = cursor.getLong(2); + + FaceEntity entity = new FaceEntity(userName, null, featureData); + entity.setUserType("2"); + entity.setRegisterTime(registerTime); + batch.add(entity); + + if (batch.size() >= BATCH_SIZE) { + FaceDatabase.getInstance(appContext).faceDao().insert(batch); + processedCount += batch.size(); + notifyProgress(callback, processedCount, totalCount); + batch.clear(); + } + } + + // 5. 写入剩余不足一批的数据 + if (!batch.isEmpty()) { + FaceDatabase.getInstance(appContext).faceDao().insert(batch); + processedCount += batch.size(); + notifyProgress(callback, processedCount, totalCount); + } + + L.d(TAG, "导入完成,共 " + processedCount + " 条记录"); + postToMain(() -> { + if (callback != null) callback.onComplete(); + }); + + } catch (Exception e) { + L.e(TAG, "导入失败: " + e.getMessage()); + postToMain(() -> { + if (callback != null) callback.onError(e.getMessage()); + }); + } finally { + if (cursor != null) cursor.close(); + if (testDb != null && testDb.isOpen()) testDb.close(); + if (tempFile != null && tempFile.exists()) tempFile.delete(); + } + }).start(); + } + + /** + * 从 assets 复制文件到指定路径 + */ + private static void copyAssetToFile(Context context, String assetPath, File destFile) + throws Exception { + try (InputStream is = context.getAssets().open(assetPath); + FileOutputStream fos = new FileOutputStream(destFile)) { + byte[] buffer = new byte[8192]; + int length; + while ((length = is.read(buffer)) > 0) { + fos.write(buffer, 0, length); + } + fos.flush(); + } + } + + private static void notifyProgress(ImportCallback callback, int current, int total) { + postToMain(() -> { + if (callback != null) { + callback.onProgress(current, total); + } + }); + } + + private static void postToMain(Runnable runnable) { + new Handler(Looper.getMainLooper()).post(runnable); + } +} diff --git a/app/src/main/java/com/sw/dualscreen/utils/FoodVectorTool.kt b/app/src/main/java/com/sw/dualscreen/utils/FoodVectorTool.kt index 85abea6..31a6e90 100644 --- a/app/src/main/java/com/sw/dualscreen/utils/FoodVectorTool.kt +++ b/app/src/main/java/com/sw/dualscreen/utils/FoodVectorTool.kt @@ -37,13 +37,13 @@ object FoodVectorTool { successBlock: () -> Unit, failureBlock: (String) -> Unit ) { - userViewModel.getCollectPage( + userViewModel.getCollectVectorPage( pageNum = pageNum.toLong(), pageSize = PAGE_SIZE.toLong(), onSuccess = { items -> if (pageNum == 1 && items.isEmpty()) { successBlock() - return@getCollectPage + return@getCollectVectorPage } if (items.isNotEmpty()) { saveFoodVector(lifecycleScope, items) diff --git a/app/src/main/java/com/sw/dualscreen/utils/PhotoCaptureHelper.kt b/app/src/main/java/com/sw/dualscreen/utils/PhotoCaptureHelper.kt index 5544464..d91585c 100644 --- a/app/src/main/java/com/sw/dualscreen/utils/PhotoCaptureHelper.kt +++ b/app/src/main/java/com/sw/dualscreen/utils/PhotoCaptureHelper.kt @@ -2,6 +2,7 @@ package com.sw.dualscreen.utils import android.content.Context import android.net.Uri +import androidx.core.content.FileProvider import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCaptureException import androidx.camera.view.CameraController @@ -22,6 +23,11 @@ class PhotoCaptureHelper( private val onSuccess: (Uri) -> Unit = {}, private val onError: (String) -> Unit = {} ) { + companion object { + /** 临时测试开关:true 时跳过拍照,直接返回固定图片Uri */ + const val TEST_MODE = true + } + private val callbackList: MutableList<(Uri) -> Unit> = mutableListOf() fun addSuccessCallback(callback:(Uri) -> Unit) { if (callbackList.contains(callback).not()) { @@ -43,6 +49,28 @@ class PhotoCaptureHelper( fileNamePrefix: String = "IMG_", fileExtension: String = ".jpg" ) { + // TODO: 测试完成后删除此段代码,并将 TEST_MODE 置为 false + if (TEST_MODE) { + val testDir = context.externalCacheDir ?: context.cacheDir + val testFile = File(testDir, "test_photo_${System.currentTimeMillis()}.jpg") + if (!testFile.exists()) { + testFile.parentFile?.mkdirs() + val bitmap = android.graphics.Bitmap.createBitmap(480, 640, android.graphics.Bitmap.Config.ARGB_8888) + val canvas = android.graphics.Canvas(bitmap) + canvas.drawColor(0xFFCCCCCC.toInt()) + testFile.outputStream().use { fos -> + bitmap.compress(android.graphics.Bitmap.CompressFormat.JPEG, 95, fos) + } + bitmap.recycle() + } + val authority = "${context.packageName}.fileprovider" + val testUri = FileProvider.getUriForFile(context, authority, testFile) + Timber.d("测试模式:跳过拍照,返回 content://Uri: $testUri") + onSuccess(testUri) + callbackList.forEach { it(testUri) } + return + } + Timber.d("开始拍照采集") try { diff --git a/app/src/main/java/com/sw/dualscreen/viewmodel/NetViewModelV2.kt b/app/src/main/java/com/sw/dualscreen/viewmodel/NetViewModelV2.kt index c1629ad..4d2371e 100644 --- a/app/src/main/java/com/sw/dualscreen/viewmodel/NetViewModelV2.kt +++ b/app/src/main/java/com/sw/dualscreen/viewmodel/NetViewModelV2.kt @@ -9,7 +9,6 @@ import com.sw.dualscreen.model.request.v2.PlaceOrderRequest import com.sw.dualscreen.model.response.ApiResponse import com.sw.dualscreen.model.response.DinnerType import com.sw.dualscreen.model.response.FoodInfo -import com.sw.dualscreen.model.response.FoodOrder import com.sw.dualscreen.model.response.FoodOrderModel import com.sw.dualscreen.model.response.FoodSearchReq import com.sw.dualscreen.model.response.MemberInfo @@ -19,6 +18,9 @@ import com.sw.dualscreen.model.response.v2.FaceVO import com.sw.dualscreen.model.response.v2.NewFoodInfo import com.sw.dualscreen.model.response.v2.NewMemberInfo import com.sw.dualscreen.model.response.v2.SettlementOrder +import com.sw.dualscreen.model.response.v2.toFoodInfo +import com.sw.dualscreen.model.response.v2.toFoodOrderModel +import com.sw.dualscreen.model.response.v2.toMemberInfo import com.sw.dualscreen.network.ApiClient import com.sw.dualscreen.utils.FileUtil import com.sw.dualscreen.utils.SpTool @@ -152,14 +154,13 @@ class NetViewModelV2 : BaseViewModel() { } fun getFoodByNames( - nameList: List, - deviceType: Int = 2, + names: String, onSuccess: (list: List) -> Unit, onFailure: (message: String) -> Unit ) { launch { try { - val response = ApiClient.repositoryV2.getFoodByNames(nameList, deviceType) + val response = ApiClient.repositoryV2.getFoodByNames(names.split(",")) if (parseResponse(response)) { onSuccess(response.data ?: emptyList()) } else { @@ -172,26 +173,6 @@ class NetViewModelV2 : BaseViewModel() { } } - fun getUserCurrentFood( - userId: Long, - onSuccess: (nutrition: UserNutrition?) -> Unit, - onFailure: (message: String) -> Unit - ) { - launch { - try { - val response = ApiClient.repositoryV2.getUserCurrentFood(userId) - if (parseResponse(response)) { - onSuccess(response.data) - } else { - onFailure(response.msg ?: "获取就餐营养数据失败") - } - } catch (e: Exception) { - Timber.e(e, "获取就餐营养数据异常") - onFailure("网络异常: ${e.message}") - } - } - } - fun placeOrder( orderRequest: PlaceOrderRequest, onSuccess: (orderNo: String?) -> Unit, @@ -212,87 +193,6 @@ class NetViewModelV2 : BaseViewModel() { } } - fun getSettlementOrders( - userId: Long, - onSuccess: (order: SettlementOrder?) -> Unit, - onFailure: (message: String) -> Unit - ) { - launch { - try { - val response = ApiClient.repositoryV2.getSettlementOrders(userId) - if (parseResponse(response)) { - onSuccess(response.data) - } else { - onFailure(response.msg ?: "查询结算订单失败") - } - } catch (e: Exception) { - Timber.e(e, "查询结算订单异常") - onFailure("网络异常: ${e.message}") - } - } - } - - fun getMemberInfo( - userId: Long, - onSuccess: (memberInfo: NewMemberInfo?) -> Unit, - onFailure: (message: String) -> Unit - ) { - launch { - try { - val response = ApiClient.repositoryV2.getMemberInfo(userId) - if (parseResponse(response)) { - onSuccess(response.data) - } else { - onFailure(response.msg ?: "获取会员信息失败") - } - } catch (e: Exception) { - Timber.e(e, "获取会员信息异常") - onFailure("网络异常: ${e.message}") - } - } - } - - fun getMemberInfoByPhoneV2( - phone: String, - password: String = "", - onSuccess: (memberInfo: NewMemberInfo?) -> Unit, - onFailure: (message: String) -> Unit - ) { - launch { - try { - val response = ApiClient.repositoryV2.getMemberInfoByPhone(phone, password) - if (parseResponse(response)) { - onSuccess(response.data) - } else { - onFailure(response.msg ?: "查询会员失败") - } - } catch (e: Exception) { - Timber.e(e, "查询会员异常") - onFailure("网络异常: ${e.message}") - } - } - } - - fun getMemberDiscount( - userId: Long, - onSuccess: (discount: String?) -> Unit, - onFailure: (message: String) -> Unit - ) { - launch { - try { - val response = ApiClient.repositoryV2.getMemberDiscount(userId) - if (parseResponse(response)) { - onSuccess(response.data) - } else { - onFailure(response.msg ?: "查询会员折扣失败") - } - } catch (e: Exception) { - Timber.e(e, "查询会员折扣异常") - onFailure("网络异常: ${e.message}") - } - } - } - fun bindUserOrder( userId: Long, orderNo: String, @@ -337,6 +237,27 @@ class NetViewModelV2 : BaseViewModel() { } } + fun getCollectVectorPage( + pageNum: Long = 1L, + pageSize: Long = PAGE_SIZE, + onSuccess: (list: List) -> Unit, + onFailure: (message: String) -> Unit + ) { + launch { + try { + val response = ApiClient.repositoryV2.getCollectVectorPage(pageNum, pageSize) + if (parseResponse(response)) { + onSuccess(response.data ?: emptyList()) + } else { + onFailure(response.msg ?: "获取采集列表失败") + } + } catch (e: Exception) { + Timber.e(e, "获取采集列表异常") + onFailure("网络异常: ${e.message}") + } + } + } + /** 上传采集图片(suspend,直接返回 picUrls 列表) */ suspend fun uploadCollect( foodId: Long, @@ -517,38 +438,22 @@ class NetViewModelV2 : BaseViewModel() { } } - /** 提交就餐营养数据 */ - fun postUserNutritionData( - param: List, - onSuccess: () -> Unit = {}, - onFailure: (message: String) -> Unit = {} - ) { - launch { - try { - val response = repository.postUserNutritionData(param) - if (parseResponse(response)) { - onSuccess() - } else { - onFailure(response.msg ?: "提交营养数据失败") - } - } catch (e: Exception) { - Timber.e(e, "提交营养数据异常") - onFailure("网络异常: ${e.message}") - } - } - } - // ========== V1 兼容包装方法(暂用 V1 API,后续逐步迁移至 V2) ========== private var lastFaceTimestamp = 0L /** 搜索食物 */ fun searchByFoodName(foodName: String, action: (List) -> Unit = {}) { - Timber.tag(TAG).d("searchByFoodName foodName = $foodName") + Timber.tag(TAG).d("searchFood foodName = $foodName") launchWithLoading { - val response = repository.getRestInfoFoodsByType(foodName = foodName) - if (parseResponse(response)) { - action(response.data ?: emptyList()) + try { + val response = ApiClient.repositoryV2.searchFood(name = foodName) + if (parseResponse(response)) { + val list = (response.data ?: emptyList()).map { it.toFoodInfo() } + action(list) + } + } catch (e: Exception) { + Timber.e(e, "搜索菜品异常") } } } @@ -559,50 +464,42 @@ class NetViewModelV2 : BaseViewModel() { block: (UserNutrition?) -> Unit ) { Timber.tag(TAG).d("getUserNutritionData userId = $userId") + val uid = userId.toLongOrNull() ?: run { + block(null) + return + } launch { - val response = repository.getUserNutritionData(userId = userId) - if (parseResponse(response)) { - block(response.data) - } else { + try { + val response = ApiClient.repositoryV2.getUserCurrentFood(uid) + if (parseResponse(response)) { + block(response.data) + } else { + block(null) + } + } catch (e: Exception) { + Timber.e(e, "获取就餐营养数据异常") block(null) } } } - /** 搜索菜品信息 */ - fun getFoodInfo(foodName: String, action: (List) -> Unit = {}) { - Timber.tag(TAG).d("getFoodInfo") - launchWithLoading { - val response = repository.getFoodInfo(foodName = foodName) - if (parseResponse(response)) { - action(response.data ?: emptyList()) - } else { - action(emptyList()) - } - } - } - - /** 创建订单 */ - fun createOrder(order: FoodOrder, block: (String) -> Unit) { - Timber.tag(TAG).d("createOrder") - launchWithLoading { - val response = repository.createOrder(order) - if (parseResponse(response)) { - block(response.data.toString()) - } else { - block("") - } - } - } - /** 获取订单列表 */ fun getFoodOrderList(userId: String, block: (FoodOrderModel?) -> Unit) { - Timber.tag(TAG).d("getFoodOrderList") + Timber.tag(TAG).d("getFoodOrderList userId = $userId") + val uid = userId.toLongOrNull() ?: run { + block(null) + return + } launchWithLoading { - val response = repository.getFoodOrderList(userId) - if (parseResponse(response)) { - block(response.data) - } else { + try { + val response = ApiClient.repositoryV2.getSettlementOrders(uid) + if (parseResponse(response)) { + block(response.data?.toFoodOrderModel()) + } else { + block(null) + } + } catch (e: Exception) { + Timber.e(e, "查询订单列表异常") block(null) } } @@ -610,12 +507,21 @@ class NetViewModelV2 : BaseViewModel() { /** 根据 ID 查询会员信息 */ fun getMemberInfoById(memberId: String, block: (MemberInfo?) -> Unit) { - Timber.tag(TAG).d("getMemberInfo") + Timber.tag(TAG).d("getMemberInfo memberId=$memberId") + val uid = memberId.toLongOrNull() ?: run { + block(null) + return + } launchWithLoading { - val response = repository.getMemberInfoById(memberId) - if (parseResponse(response)) { - block(response.data) - } else { + try { + val response = ApiClient.repositoryV2.getMemberInfo(uid) + if (parseResponse(response)) { + block(response.data?.toMemberInfo()) + } else { + block(null) + } + } catch (e: Exception) { + Timber.e(e, "查询会员信息异常") block(null) } } @@ -623,38 +529,39 @@ class NetViewModelV2 : BaseViewModel() { /** 根据手机号查询会员信息 */ fun getMemberInfoByPhone(phone: String, key: String, block: (MemberInfo?) -> Unit) { - Timber.tag(TAG).d("getMemberInfoByPhone") + Timber.tag(TAG).d("getMemberInfoByPhone phone=$phone") launchWithLoading { - val response = repository.getMemberInfoByPhone(phone, key) - if (parseResponse(response)) { - block(response.data) - } else { + try { + val response = ApiClient.repositoryV2.getMemberInfoByPhone(phone, key) + if (parseResponse(response)) { + block(response.data?.toMemberInfo()) + } else { + block(null) + } + } catch (e: Exception) { + Timber.e(e, "查询会员异常") block(null) } } } - /** 绑定订单 */ - fun bindOrder(userId: String, orderId: String, mode: Int, block: (Boolean) -> Unit) { - Timber.tag(TAG).d("bindOrder") - launchWithLoading { - val response = repository.bindOrder(userId = userId, orderId = orderId, mode = mode) - if (parseResponse(response)) { - block(true) - } else { - block(false) - } - } - } - /** 获取会员折扣 */ fun getMemberDiscount(userId: String, block: (Double?) -> Unit) { - Timber.tag(TAG).d("getMemberDiscount") + Timber.tag(TAG).d("getMemberDiscount userId = $userId") + val uid = userId.toLongOrNull() ?: run { + block(null) + return + } launchWithLoading { - val response = repository.getMemberDiscount(userId) - if (parseResponse(response)) { - block(response.data) - } else { + try { + val response = ApiClient.repositoryV2.getMemberDiscount(uid) + if (parseResponse(response)) { + block(response.data?.toDoubleOrNull()) + } else { + block(null) + } + } catch (e: Exception) { + Timber.e(e, "查询会员折扣异常") block(null) } }