Author SHA1 Message Date
mazengfeiandClaude Fable 5 03e6c9a6bb feat(log): 增加日志定期清理机制,防止长期运行日志无限累加
- FileLogger 新增过期清理(保留7天)及单文件100MB写入上限
- CrashHandler 激活崩溃日志清理并补充异常保护
- App 启动清理 + 运行中每24小时定时清理,覆盖设备长期不断电场景

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 14:57:32 +08:00
mazengfei c77c32fde1 refactor(model): 删除已废弃的分页响应包装类PageData
- 移除PageData数据类及其相关注释
- 精简代码库,减少不必要的数据封装
- 为后续分页功能重构做好准备
- 保持代码结构清晰,提升可维护性
2026-08-09 17:59:22 +08:00
mazengfei dec5ecc100 refactor(api): 优化净菜包和餐品净菜包接口返回结构
- 移除净菜包和餐品净菜包接口中分页包装PageData,接口直接返回列表数组
- 修改相关调用处,取消对PageData的类型转换,直接处理列表数据
- 清理无用的PageData导入语句
- 保持接口功能和调用逻辑一致,提升代码简洁性和易维护性
2026-08-09 17:59:04 +08:00
mazengfei 5dc0bb2ec5 feat(model): 添加分页响应包装和分页请求体数据类
- 新增 PageData 泛型数据类,封装分页响应数据结构
- 增加 list、total、pageNum、pageSize 四个分页字段注释说明
- 新增 PageOptionsReq 数据类,用于分页请求参数封装
- pageNum、pageSize 必填,keyword 用于搜索或列表返回
- 适配净菜包及餐品净菜包下拉接口请求需求
2026-08-09 17:42:10 +08:00
mazengfei c3682d1ed3 refactor(api): 更新净菜包和餐品净菜包接口支持分页请求
- 使用 POST 请求和 JSON Body 替换 GET 请求,支持分页参数传递
- 更新接口返回类型为分页结构 PageData
- 在对应数据模型中新增分页请求参数类 PageOptionsReq 引入
- 增加 CleanPackageOption 和 MealPackageOption 模型字段以支持新需求
- 调整 NetViewModelV2 中调用接口逻辑,传递分页参数
- 修改 ShelfV2Activity 中数据处理逻辑,适配分页返回数据结构
- 更新全局常量 PROD_BASE_URL 地址
2026-08-09 17:07:13 +08:00
9 changed files with 125 additions and 18 deletions
@@ -5,8 +5,11 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Handler
import android.os.Looper
import com.shuwei.intelligent.shelves.utils.BootReceiver import com.shuwei.intelligent.shelves.utils.BootReceiver
import com.shuwei.intelligent.shelves.utils.CrashHandler import com.shuwei.intelligent.shelves.utils.CrashHandler
import com.shuwei.intelligent.shelves.utils.FileLogger
/** /**
* @author: star * @author: star
@@ -19,10 +22,22 @@ class App : Application() {
super.onCreate() super.onCreate()
app = this app = this
CrashHandler.init(this) CrashHandler.init(this)
// 启动时立即清理一次过期日志
cleanupLogs()
// 设备长期不断电、进程不会重新启动,运行中每24小时定期清理一次
logCleanupHandler.postDelayed(logCleanupRunnable, LOG_CLEANUP_INTERVAL)
val filter = IntentFilter(Intent.ACTION_BOOT_COMPLETED) val filter = IntentFilter(Intent.ACTION_BOOT_COMPLETED)
registerReceiver(BootReceiver(), filter) registerReceiver(BootReceiver(), filter)
} }
/**
* 清理过期的日志文件:文件日志与崩溃日志均保留最近7天
*/
private fun cleanupLogs() {
FileLogger.cleanupExpiredLogs(this)
CrashHandler.getInstance()?.cleanupOldCrashReports()
}
companion object { companion object {
private lateinit var app: App private lateinit var app: App
var canteenId = "0" var canteenId = "0"
@@ -36,6 +51,19 @@ class App : Application() {
@Volatile @Volatile
private var sharedPref: SharedPreferences? = null private var sharedPref: SharedPreferences? = null
/** 日志定期清理间隔:24小时,与日志文件按天滚动对齐 */
private const val LOG_CLEANUP_INTERVAL = 24L * 60 * 60 * 1000
private val logCleanupHandler = Handler(Looper.getMainLooper())
/** 日志定期清理任务:执行后重新调度自身,形成循环 */
private val logCleanupRunnable = object : Runnable {
override fun run() {
getInstance().cleanupLogs()
logCleanupHandler.postDelayed(this, LOG_CLEANUP_INTERVAL)
}
}
fun getInstance(): App { fun getInstance(): App {
return app return app
} }
@@ -20,7 +20,7 @@ object GlobalData {
// const val LOCAL_BASE_URL = "http://192.168.1.201:14801" // const val LOCAL_BASE_URL = "http://192.168.1.201:14801"
const val LOCAL_BASE_URL = "http://192.168.10.101:24801" const val LOCAL_BASE_URL = "http://192.168.10.101:24801"
const val TEST_BASE_URL = "https://dev.yixiong-tech.com:8081" const val TEST_BASE_URL = "https://dev.yixiong-tech.com:8081"
const val PROD_BASE_URL = "https://api.dm.yixiong-tech.com:8443" const val PROD_BASE_URL = "https://platform-api.uat.shuziweidao.com"
} }
@@ -21,6 +21,14 @@ data class CleanPackageOption(
val needQtyKg: BigDecimal? = null, val needQtyKg: BigDecimal? = null,
/** 保质期至,格式 yyyy-MM-dd */ /** 保质期至,格式 yyyy-MM-dd */
val expiryDate: String? = null, val expiryDate: String? = null,
/** 申领时间,格式 yyyy-MM-dd HH:mm:ss,仅订单有 */
val applyTime: String? = null,
/** 数据来源:1=净菜订单,2=食材库 */
val source: Short? = null,
/** 净菜类型 id,仅订单有 */
val vegTypeId: Long? = null,
/** 食材库 idsource=2 时有值,sync 时回传 materId */
val materId: Long? = null,
var isSelected: Boolean = false var isSelected: Boolean = false
) { ) {
/** 将净菜包选项转换为格口记录 */ /** 将净菜包选项转换为格口记录 */
@@ -21,6 +21,8 @@ data class MealPackageOption(
val needQtyKg: BigDecimal? = null, val needQtyKg: BigDecimal? = null,
/** 保质期至,格式 yyyy-MM-dd(包装完成后有值) */ /** 保质期至,格式 yyyy-MM-dd(包装完成后有值) */
val expiryDate: String? = null, val expiryDate: String? = null,
/** 数据来源:1=净菜订单,2=餐品库 */
val source: Short? = null,
var isSelected: Boolean = false var isSelected: Boolean = false
) { ) {
/** 将餐品净菜包选项转换为格口记录 */ /** 将餐品净菜包选项转换为格口记录 */
@@ -0,0 +1,18 @@
package com.shuwei.intelligent.shelves.model
/**
* 下拉列表分页请求体
*
* 用于净菜包/餐品净菜包下拉接口(POST),
* pageNum、pageSize 必填,keyword 可选:
* - 不传 keyword:返回净菜订单列表
* - 传 keyword:搜索食材库/餐品库
*/
data class PageOptionsReq(
/** 页码,从 1 开始 */
val pageNum: Long = 1,
/** 每页条数 */
val pageSize: Long = 50,
/** 搜索关键字,不传返回订单列表,传值搜索库表 */
val keyword: String? = null
)
@@ -3,6 +3,7 @@ package com.shuwei.intelligent.shelves.net
import com.shuwei.intelligent.shelves.model.CabinetInitResult import com.shuwei.intelligent.shelves.model.CabinetInitResult
import com.shuwei.intelligent.shelves.model.CleanPackageOption import com.shuwei.intelligent.shelves.model.CleanPackageOption
import com.shuwei.intelligent.shelves.model.MealPackageOption import com.shuwei.intelligent.shelves.model.MealPackageOption
import com.shuwei.intelligent.shelves.model.PageOptionsReq
import com.shuwei.intelligent.shelves.model.SyncBody import com.shuwei.intelligent.shelves.model.SyncBody
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
@@ -24,20 +25,22 @@ interface ApiServiceV2 {
/** /**
* 4.2.1 净菜包下拉列表(itemType=1 * 4.2.1 净菜包下拉列表(itemType=1
* POST + JSON Body 分页请求,data 直接为列表数组
*/ */
@GET @POST
suspend fun getCleanPackageOptions( suspend fun getCleanPackageOptions(
@Url url: String = UrlConfig.CLEAN_PACKAGE_OPTIONS, @Url url: String = UrlConfig.CLEAN_PACKAGE_OPTIONS,
@Query("keyword") keyword: String? = null @Body body: PageOptionsReq
): RespData<List<CleanPackageOption>> ): RespData<List<CleanPackageOption>>
/** /**
* 4.2.2 餐品净菜包下拉列表(itemType=2) * 4.2.2 餐品净菜包下拉列表(itemType=2)
* POST + JSON Body 分页请求,data 直接为列表数组
*/ */
@GET @POST
suspend fun getMealPackageOptions( suspend fun getMealPackageOptions(
@Url url: String = UrlConfig.MEAL_PACKAGE_OPTIONS, @Url url: String = UrlConfig.MEAL_PACKAGE_OPTIONS,
@Query("keyword") keyword: String? = null @Body body: PageOptionsReq
): RespData<List<MealPackageOption>> ): RespData<List<MealPackageOption>>
/** /**
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import com.shuwei.intelligent.shelves.model.CabinetInitResult import com.shuwei.intelligent.shelves.model.CabinetInitResult
import com.shuwei.intelligent.shelves.model.CleanPackageOption import com.shuwei.intelligent.shelves.model.CleanPackageOption
import com.shuwei.intelligent.shelves.model.MealPackageOption import com.shuwei.intelligent.shelves.model.MealPackageOption
import com.shuwei.intelligent.shelves.model.PageOptionsReq
import com.shuwei.intelligent.shelves.model.SyncBody import com.shuwei.intelligent.shelves.model.SyncBody
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -47,7 +48,9 @@ class NetViewModelV2 : ViewModel() {
viewModelScope.launch { viewModelScope.launch {
_cleanPackageOptionsUiState.value = UiState.Loading _cleanPackageOptionsUiState.value = UiState.Loading
runCatching { runCatching {
val response = apiServiceV2.getCleanPackageOptions(keyword = keyword) val response = apiServiceV2.getCleanPackageOptions(
body = PageOptionsReq(pageNum = 1, pageSize = 50, keyword = keyword)
)
if (response.isSuccess()) { if (response.isSuccess()) {
_cleanPackageOptionsUiState.value = UiState.Success(response) _cleanPackageOptionsUiState.value = UiState.Success(response)
} else { } else {
@@ -64,7 +67,9 @@ class NetViewModelV2 : ViewModel() {
viewModelScope.launch { viewModelScope.launch {
_mealPackageOptionsUiState.value = UiState.Loading _mealPackageOptionsUiState.value = UiState.Loading
runCatching { runCatching {
val response = apiServiceV2.getMealPackageOptions(keyword = keyword) val response = apiServiceV2.getMealPackageOptions(
body = PageOptionsReq(pageNum = 1, pageSize = 50, keyword = keyword)
)
if (response.isSuccess()) { if (response.isSuccess()) {
_mealPackageOptionsUiState.value = UiState.Success(response) _mealPackageOptionsUiState.value = UiState.Success(response)
} else { } else {
@@ -39,11 +39,19 @@ class CrashHandler private constructor(private val context: Context) :
synchronized(CrashHandler::class.java) { synchronized(CrashHandler::class.java) {
if (instance == null) { if (instance == null) {
instance = CrashHandler(context.applicationContext) instance = CrashHandler(context.applicationContext)
// 初始化时清理过期的崩溃日志(默认保留7天)
instance?.cleanupOldCrashReports()
} }
} }
} }
} }
/**
* 获取单例实例
* @return 已通过 init() 初始化时返回实例,否则返回 null
*/
fun getInstance(): CrashHandler? = instance
//fun getCrashReportFiles(context: Context): Array<File> { //fun getCrashReportFiles(context: Context): Array<File> {
// val crashDir = getCrashDir() // val crashDir = getCrashDir()
// return if (crashDir.exists() && crashDir.isDirectory) { // return if (crashDir.exists() && crashDir.isDirectory) {
@@ -222,16 +230,21 @@ class CrashHandler private constructor(private val context: Context) :
* 清理旧的崩溃日志 * 清理旧的崩溃日志
*/ */
fun cleanupOldCrashReports(maxAgeDays: Int = 7) { fun cleanupOldCrashReports(maxAgeDays: Int = 7) {
val crashDir = getCrashDir() // 该方法会在 Application 启动及定时任务中调用,异常必须内部消化,避免影响主流程
if (!crashDir.exists() || !crashDir.isDirectory) return try {
val crashDir = getCrashDir()
if (!crashDir.exists() || !crashDir.isDirectory) return
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val maxAgeMillis = maxAgeDays * 24 * 60 * 60 * 1000L val maxAgeMillis = maxAgeDays * 24 * 60 * 60 * 1000L
crashDir.listFiles()?.forEach { file -> crashDir.listFiles()?.forEach { file ->
if (file.lastModified() < now - maxAgeMillis) { if (file.lastModified() < now - maxAgeMillis) {
file.delete() file.delete()
}
} }
} catch (e: Exception) {
Log.e(TAG, "清理崩溃日志失败", e)
} }
} }
@@ -17,8 +17,37 @@ class FileLogger(private val context: Context) {
private const val LOG_DIR = "logs" private const val LOG_DIR = "logs"
private const val FILE_PREFIX = "log_" private const val FILE_PREFIX = "log_"
private const val FILE_EXTENSION = ".txt" private const val FILE_EXTENSION = ".txt"
/** 日志保留天数,超过该天数的日志文件在应用启动时清理 */
private const val RETENTION_DAYS = 7L
/** 单个日志文件大小上限(100MB),超过后当天不再写入,防止异常刷屏撑爆存储 */
private const val MAX_LOG_FILE_SIZE = 100L * 1024 * 1024
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
private val timeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) private val timeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
/**
* 清理过期的日志文件,保留最近 retentionDays 天
* 按文件最后修改时间判断,最近仍在写入的文件不会被误删
* 建议在 Application 启动时调用一次
* @param context 上下文,用于定位 filesDir
* @param retentionDays 日志保留天数
*/
fun cleanupExpiredLogs(context: Context, retentionDays: Long = RETENTION_DAYS) {
try {
val cutoff = System.currentTimeMillis() - retentionDays * 24 * 60 * 60 * 1000L
context.filesDir.listFiles { _, name ->
name.startsWith(FILE_PREFIX) && name.endsWith(FILE_EXTENSION)
}?.forEach { file ->
if (file.lastModified() < cutoff) {
file.delete()
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
} }
/** /**
@@ -45,6 +74,7 @@ class FileLogger(private val context: Context) {
/** /**
* 写入文件 * 写入文件
* 单文件超过大小上限(100MB)时当天不再写入,防止异常刷屏撑爆存储
*/ */
private fun writeToFile(logEntry: String) { private fun writeToFile(logEntry: String) {
// val path = File(context.filesDir, LOG_DIR) // val path = File(context.filesDir, LOG_DIR)
@@ -52,10 +82,10 @@ class FileLogger(private val context: Context) {
// path.mkdirs() // path.mkdirs()
// } // }
val fileName = getCurrentDateFileName() val fileName = getCurrentDateFileName()
// val file = File(path, fileName) val file = File(context.filesDir, fileName)
// if (file.exists().not()) { if (file.exists() && file.length() > MAX_LOG_FILE_SIZE) {
// file.createNewFile() return
// } }
val output = context.openFileOutput(fileName, Context.MODE_APPEND) val output = context.openFileOutput(fileName, Context.MODE_APPEND)
val writer = BufferedWriter(OutputStreamWriter(output)) val writer = BufferedWriter(OutputStreamWriter(output))