diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8895293..e2ab042 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -223,6 +223,8 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.compiler) implementation(libs.androidx.hilt.navigation.compose) + // 后台用量同步(WorkManager 周期任务):版本已在 libs.versions.toml 中声明 + implementation(libs.androidx.work.runtime.ktx) // Room implementation(libs.room.runtime) diff --git a/app/src/main/java/com/rainy/token/RainyTokenApplication.kt b/app/src/main/java/com/rainy/token/RainyTokenApplication.kt index 1939403..e027059 100644 --- a/app/src/main/java/com/rainy/token/RainyTokenApplication.kt +++ b/app/src/main/java/com/rainy/token/RainyTokenApplication.kt @@ -2,7 +2,10 @@ package com.rainy.token import android.app.Application import android.content.Context +import com.rainy.token.domain.usecase.UsageSyncCoordinator +import com.rainy.token.sync.UsageSyncScheduler import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject /** * RainyToken 入口 Application。 @@ -12,6 +15,10 @@ import dagger.hilt.android.HiltAndroidApp */ @HiltAndroidApp class RainyTokenApplication : Application() { + + @Inject + lateinit var syncCoordinator: UsageSyncCoordinator + override fun attachBaseContext(base: Context) { // 应用内语言偏好(跟随系统 / 中文 / English)在 Application 层生效, // 保证 appContext 与系统级回调(如小组件)之外的代码都使用所选语言。 @@ -21,6 +28,10 @@ class RainyTokenApplication : Application() { override fun onCreate() { super.onCreate() appContext = applicationContext + // 后台用量同步兜底:按用户开关注册 / 取消 8 小时周期任务。 + // 服务端明细窗口约 24h,只要每天有一次成功同步就不会丢数据; + // 真正的主力是前台进入页面时的自动同步,这里只负责「忘了打开 app」的情况。 + UsageSyncScheduler.apply(this, syncCoordinator.backgroundSyncEnabled) } companion object { diff --git a/app/src/main/java/com/rainy/token/data/local/UsageCache.kt b/app/src/main/java/com/rainy/token/data/local/UsageCache.kt index 924ea32..a351852 100644 --- a/app/src/main/java/com/rainy/token/data/local/UsageCache.kt +++ b/app/src/main/java/com/rainy/token/data/local/UsageCache.kt @@ -45,11 +45,17 @@ class UsageCache( emit(dao.getAll().map { it.toDomain() }) } - suspend fun insertAll(newRecords: List) { + /** + * 批量插入,返回**真正写入**的条数。 + * + * DAO 使用 OnConflictStrategy.IGNORE:已存在的 id 会被忽略且返回 -1L,可据此精确计数。 + * 旧实现用全表 count() 差值统计插入数,会被并发的其它服务同步污染。 + */ + suspend fun insertAll(newRecords: List): Int { ensureMigrated() - if (newRecords.isEmpty()) return + if (newRecords.isEmpty()) return 0 val entities = newRecords.map { it.toEntity() } - dao.insertAll(entities) + return dao.insertAll(entities).count { it != -1L } } suspend fun getLatest(): UsageRecord? { diff --git a/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt index 008ca46..c47e1a1 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt @@ -21,15 +21,32 @@ import java.util.Locale import java.util.TimeZone import javax.inject.Singleton +/** + * 单页抓取结果。 + * + * 前两个字段保持 (records, nextCursor) 顺序,兼容既有 `val (records, next) = parse(...)` 解构。 + * + * @param rawCount 服务端原始返回条数(解析前),用于判断「是否满页」——不能用 records.size, + * 否则个别记录解析失败会被误判为「到底」并静默截断同步。 + * @param droppedCount 解析失败被丢弃的条数,用于把解析异常暴露成同步结果而不是静默吞掉。 + */ +data class UsagePage( + val records: List, + val nextCursor: String?, + val rawCount: Int = 0, + val droppedCount: Int = 0 +) + /** * CommandCode 用量记录仓库。 * * 调 JSON API 分页抓取 usage 记录: - * GET https://api.commandcode.ai/internal/usage?limit=50 - * GET https://api.commandcode.ai/internal/usage?limit=50&cursor= + * GET https://api.commandcode.ai/internal/usage?limit=100 + * GET https://api.commandcode.ai/internal/usage?limit=100&cursor= * - * cursor 是末条记录的 { createdAt, id } 的 base64 编码。 - * 第一页不用 cursor。 + * cursor 是末条记录的 { createdAt, id } 的 base64 编码(服务端 nextCursor 目前恒为 null, + * 因此由客户端按末条自编码回退)。第一页不用 cursor。 + * 注意:服务端只保留最近约 24 小时的明细(滚动窗口),超过窗口的历史无法通过该接口取回。 */ @Singleton class CommandCodeUsageRepository( @@ -60,25 +77,30 @@ class CommandCodeUsageRepository( * - meta 新增 totalCost / inputCost / outputCost / cacheCost / traceId; * provider / cacheReadInputTokens 已移除。 */ - internal fun parseUsageResponse(body: String): Pair, String?> { + internal fun parseUsageResponse(body: String): UsagePage { val root = json.parseToJsonElement(body).jsonObject - val usages = root["usages"]?.jsonArray ?: return emptyList() to null + val usages = root["usages"]?.jsonArray + ?: return UsagePage(emptyList(), null, 0, 0) val records = usages.mapNotNull { elem -> parseUsageObject(elem.jsonObject) } + val rawCount = usages.size + val dropped = rawCount - records.size // 新格式:优先用服务端游标;缺失或显式 null 时回退按末条自编码(兼容旧格式) // 注意:JSON null 的 jsonPrimitive.content 是字符串 "null",必须用 is JsonNull 拦截, // 否则会把 "null" 当游标传给服务端(被忽略→返回第一页),导致 fullSync 死循环。 val serverCursor = root["nextCursor"]?.takeIf { it !is JsonNull } ?.jsonPrimitive?.content?.takeIf { it.isNotEmpty() } - val nextCursor = serverCursor ?: if (records.size >= PAGE_SIZE) { - val last = records.last() - encodeCursor(last.id, last.timeCreated) + // ★ 满页判定必须用服务端原始条数 rawCount,不能用解析后的 records.size: + // 任意一条记录解析失败就会让解析后条数少 1,旧实现据此判定「到底」并静默中断整次同步, + // 本地因此永久留下空洞(2026-09 实测确认的成因之一)。 + val nextCursor = serverCursor ?: if (rawCount >= PAGE_SIZE) { + records.lastOrNull()?.let { encodeCursor(it.id, it.timeCreated) } } else null - return records to nextCursor + return UsagePage(records, nextCursor, rawCount, dropped) } /** @@ -178,9 +200,10 @@ class CommandCodeUsageRepository( /** * 获取指定游标页的用量记录。 * cursor=null 为最新页。 - * 返回 (记录列表, 下一页游标)。如果返回的列表长度 < PAGE_SIZE,表示到底。 + * 返回 [UsagePage]:是否到底由服务端原始条数(rawCount)判定, + * 不能用解析后条数 —— 个别记录解析失败会让解析后条数变少而被误判为「到底」。 */ - suspend fun fetchPage(cursor: String?): Result, String?>> = + suspend fun fetchPage(cursor: String?): Result = withContext(Dispatchers.IO) { val cookieHeader = try { getCookieHeader() @@ -209,7 +232,13 @@ class CommandCodeUsageRepository( } response.use { resp -> - val body = resp.body?.string() + val body = try { + resp.body?.string() + } catch (e: IOException) { + // 连接中途断开时 string() 会抛 IOException:不能让异常穿出 fetchPage + //(前台同步挂在 viewModelScope 上,未捕获会直接崩主线程) + return@withContext Result.failure(RepositoryError.Network(e)) + } if (!resp.isSuccessful) { if (resp.code == 401 || resp.code == 403) { val detail = if (body != null && body.length < 200) ":$body" else "" @@ -224,8 +253,19 @@ class CommandCodeUsageRepository( RepositoryError.ParseError(RepositoryError.ParseErrorReason.EMPTY_BODY, "响应体为空") ) - val records = parseUsageResponse(body) - Result.success(records) + val page = try { + parseUsageResponse(body) + } catch (e: Exception) { + // 200 但响应不是预期 JSON(网关 HTML / 维护页等):降级为解析错误, + // 不能让异常穿出 fetchPage —— 前台同步挂在 viewModelScope 上,会直接崩主线程。 + return@withContext Result.failure( + RepositoryError.ParseError( + RepositoryError.ParseErrorReason.MALFORMED_RESPONSE, + e.message ?: "unparsable usage response" + ) + ) + } + Result.success(page) } } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/domain/usecase/SyncCommandCodeUsageUseCase.kt b/app/src/main/java/com/rainy/token/domain/usecase/SyncCommandCodeUsageUseCase.kt index e1c4aa0..0a8927a 100644 --- a/app/src/main/java/com/rainy/token/domain/usecase/SyncCommandCodeUsageUseCase.kt +++ b/app/src/main/java/com/rainy/token/domain/usecase/SyncCommandCodeUsageUseCase.kt @@ -10,22 +10,27 @@ import javax.inject.Provider * * 游标协议:每页返回 (记录列表, 下一页游标)。 * - cursor=null → 最新页 - * - 返回的列表长度 < PAGE_SIZE → 到底 + * - 服务端 nextCursor 缺失时按末条自编码回退;「是否到底」以服务端原始条数为准 * - * ## 全量同步 - * 从 cursor=null 逐页抓取直至最后一页。 + * ## 三种同步的分工 + * - [refreshWindow] 立即同步(用户主动点刷新):从 cursor=null 连续向前翻,拉满服务端窗口, + * 不把「本地已有」当停止信号,能补齐窗口内被服务端补录 / 曾漏掉的记录; + * - [incrementalSync] 增量(自动路径:回到前台 / 8h 后台任务):**整页记录本地已存在即停**, + * 健康状态下 1 个请求即可,省请求,但探不到窗口内的空洞; + * - [fullSync] 全量:逐页抓到服务端没有更多。 * - * ## 增量同步 - * 从 cursor=null 逐页抓取,每页比对本地已有 ID, - * 当某页全部记录都已存在时停止。 + * 注意:「整页命中即停」只说明最新一页本地已有,**不代表窗口完整**——补洞要用 [refreshWindow]。 */ class SyncCommandCodeUsageUseCase @Inject constructor( private val usageRepoProvider: Provider, private val cacheProvider: Provider ) { - /** 防御性页数上限:正常窗口(1 天 ≤ 数十页)远不会触及,防止游标异常导致死循环 */ private companion object { + /** 全量拉取的防御性页数上限:防止游标异常导致死循环 */ const val MAX_PAGES = 500 + + /** 立即同步(refreshWindow)的页数上限:100 条/页 → 5000 条;服务端 24h 窗口远小于此 */ + const val MAX_WINDOW_PAGES = 50 } suspend fun fullSync(): Result { @@ -42,53 +47,203 @@ class SyncCommandCodeUsageUseCase @Inject constructor( errors.add("cursor=${cursor?.take(20)}: ${pageResult.exceptionOrNull()?.message}") break } - val (records, nextCursor) = pageResult.getOrThrow() - if (records.isEmpty()) break - if (nextCursor == cursor) break // 游标未前进,防死循环 + val page = pageResult.getOrThrow() + if (page.records.isEmpty() && page.rawCount > 0) { + // 服务端有数据、但整页都解析失败:不能当作「到底」,否则会静默漏掉整页 + errors.add("parse anomaly: raw=${page.rawCount} dropped=${page.droppedCount}") + break + } + if (page.records.isEmpty()) break + if (page.nextCursor == cursor) break // 游标未前进,防死循环 - val before = cache.count() - cache.insertAll(records) - totalInserted += (cache.count() - before) + // 精确计数:DAO 的 IGNORE 策略下已存在的行返回 -1; + // 旧实现用全表 count() 差值,会被并发的其它服务同步污染。 + totalInserted += cache.insertAll(page.records) - if (records.size < CommandCodeUsageRepository.PAGE_SIZE) break - cursor = nextCursor - if (++pages >= MAX_PAGES) break // 防御性上限,正常窗口不会触及 + cursor = page.nextCursor + if (cursor == null) break // 服务端没有更多(正常到底) + if (++pages >= MAX_PAGES) { + // 触顶 = 行走被截断,不能算成功:旧实现静默返回 success,会把空洞伪装成「已同步」 + errors.add("page limit $MAX_PAGES reached") + break + } } - return if (errors.isEmpty()) Result.success(SyncResult(inserted = totalInserted)) - else Result.failure(SyncError.PartialSync(totalInserted, errors)) + return if (errors.isEmpty()) Result.success( + SyncResult(inserted = totalInserted, pages = pages, stopReason = SyncStopReason.BOTTOM) + ) else Result.failure(SyncError.PartialSync(totalInserted, errors)) } + /** + * 立即同步(用户主动触发):把服务端当前窗口内的记录一次性拉满。 + * + * 与 [incrementalSync] 的分工:这里**不把「本地已有」当作停止信号**, + * 因此能补上窗口内被服务端补录 / 曾漏掉的记录(代价是多几个请求)。 + * 服务端只保留最近约 24 小时的明细(滚动窗口),窗口之外的历史无法回补。 + * 从最新页连续向前翻,直到:服务端没有更多 / 游标不前进 / 触顶 / 出错。 + */ + suspend fun refreshWindow(): Result { + val repo = usageRepoProvider.get() + val cache = cacheProvider.get() + var cursor: String? = null + var inserted = 0 + var scanned = 0 + var dropped = 0 + var pages = 0 + val errors = mutableListOf() + var stop = SyncStopReason.BOTTOM + + while (true) { + val pageResult = repo.fetchPage(cursor) + if (pageResult.isFailure) { + val cause = pageResult.exceptionOrNull() + // 首页(cursor 为 null)就失败:此时一条记录都还没写入,直接把原异常冒泡—— + // UI 能显示精确文案(凭据失效 / 网络异常 / 服务端错误 / 限流), + // 后台 Worker 也能据此判断该不该重试。否则会被 PartialSync 吞成「部分同步」。 + if (cursor == null && cause != null) { + return Result.failure(cause) + } + errors.add("cursor=${cursor?.take(20)}: ${cause?.message}") + stop = SyncStopReason.PAGE_ERROR + break + } + + val page = pageResult.getOrThrow() + pages++ + scanned += page.rawCount + dropped += page.droppedCount + + if (page.records.isEmpty() && page.rawCount > 0) { + // 服务端有数据但整页解析失败:不当作「到底」,而是显式中断并上报 + stop = SyncStopReason.PARSE_ANOMALY + break + } + + inserted += cache.insertAll(page.records) + + val next = page.nextCursor + if (next == null) { stop = SyncStopReason.BOTTOM; break } + if (next == cursor) { stop = SyncStopReason.CURSOR_STUCK; break } + if (pages >= MAX_WINDOW_PAGES) { stop = SyncStopReason.MAX_PAGES; break } + cursor = next + } + + val result = SyncResult( + inserted = inserted, + scanned = scanned, + dropped = dropped, + pages = pages, + stopReason = stop + ) + // 只有「真追平服务端窗口」才算成功:被页数上限截断、服务端数据解析异常、游标卡住 + // 都属于「没拉完」,必须上报——否则协调器会刷新 lastSyncAt、把窗口守护清掉, + // 用户看到的是「同步成功、新增 0 条」,洞却被悄悄留着。 + // 部分解析失败:只有丢失显著(≥10 条,或 ≥ 本次扫描的一半)才按「数据没拿全」上报。 + // 偶发个别脏记录不足以让整次同步失败——否则 lastSyncAt 会永久冻住、窗口守护常挂、后台还会无限重试。 + val droppedSignificantly = dropped >= 10 || (scanned > 0 && dropped * 2 >= scanned) + val abnormal = when { + stop == SyncStopReason.MAX_PAGES -> "page limit $MAX_WINDOW_PAGES reached" + stop == SyncStopReason.PARSE_ANOMALY -> "parse anomaly: dropped=$dropped" + stop == SyncStopReason.CURSOR_STUCK -> "cursor did not advance" + droppedSignificantly -> "dropped $dropped of $scanned record(s) while parsing" + else -> null + } + return if (errors.isEmpty() && abnormal == null) Result.success(result) + else Result.failure( + SyncError.PartialSync(inserted, errors + listOfNotNull(abnormal)) + ) + } + + /** + * 增量同步(自动同步用):从最新页向前翻,**某页记录本地全部已存在时停止**。 + * + * 保留这个启发式的理由:健康状态下只要 1 个请求就能确认「已接到已知数据」, + * 前台与后台每天会触发多次,这是成本最低的做法。 + * + * 与 [refreshWindow] 的分工: + * - 增量(本方法):自动路径(回到前台、8h 后台任务)——省请求,但不保证填洞; + * - 窗口拉满:[refreshWindow] ——用户主动点「立即同步」时用,多几个请求换「窗口内补齐」。 + * + * 与旧实现的差别(旧实现的两个缺陷): + * 1. 不再用「解析后条数」判断满页/到底 —— 一条记录解析失败曾会导致整页被当成已知数据; + * 2. 命中即停会作为 [SyncStopReason.HIT_EXISTING] 上报,不再与「窗口真拉完」混为一谈。 + * + * 注意:命中即停只说明「最新一页本地已有」,**不代表窗口内没有空洞**;它不算失败(返回 success), + * 但也不应被当作「服务端窗口已拉完」——补洞要靠 [refreshWindow]。 + */ suspend fun incrementalSync(): Result { val repo = usageRepoProvider.get() val cache = cacheProvider.get() var cursor: String? = null - var totalInserted = 0 + var inserted = 0 + var scanned = 0 + var dropped = 0 var pages = 0 + val errors = mutableListOf() + var stop = SyncStopReason.BOTTOM + // 行程开始前取一次本地已有 ID(避免每页重复扫全表;本行程新插入的 id 不会再出现在后续页) + val existingIds = cache.getIdsByWorkspace(CommandCodeUsageRepository.CCGO_WORKSPACE_ID) while (true) { val pageResult = repo.fetchPage(cursor) - if (pageResult.isFailure) return Result.failure(pageResult.exceptionOrNull()!!) + if (pageResult.isFailure) { + val cause = pageResult.exceptionOrNull() + // 首页失败:直接冒泡原异常(UI 文案精确,后台可据此判断是否值得重试) + if (cursor == null && cause != null) return Result.failure(cause) + errors.add("cursor=${cursor?.take(20)}: ${cause?.message}") + stop = SyncStopReason.PAGE_ERROR + break + } - val (records, nextCursor) = pageResult.getOrThrow() - if (records.isEmpty()) break - if (nextCursor == cursor) break // 游标未前进,防死循环 + val page = pageResult.getOrThrow() + pages++ + scanned += page.rawCount + dropped += page.droppedCount - // 按 workspace 过滤本地已有 ID,避免跨 workspace 碰撞 - val workspaceId = records.firstOrNull()?.workspaceId ?: CommandCodeUsageRepository.CCGO_WORKSPACE_ID - val existingIds = cache.getIdsByWorkspace(workspaceId) - val newRecords = records.filter { it.id !in existingIds } + if (page.records.isEmpty() && page.rawCount > 0) { + // 服务端有数据但整页解析失败:按异常处理,不停成「已接到旧数据」 + stop = SyncStopReason.PARSE_ANOMALY + break + } - if (newRecords.isEmpty()) break + // 整页命中判定基于服务端原始条数:个别记录解析失败不应让整页被误判为「已存在」 + val newRecords = page.records.filter { it.id !in existingIds } - cache.insertAll(newRecords) - totalInserted += newRecords.size + if (page.rawCount > 0 && newRecords.isEmpty()) { + // 整页都是已知数据 → 已接到旧数据,停止(保留既有启发式) + stop = SyncStopReason.HIT_EXISTING + break + } - if (records.size < CommandCodeUsageRepository.PAGE_SIZE) break - cursor = nextCursor - if (++pages >= MAX_PAGES) break // 防御性上限 + inserted += cache.insertAll(newRecords) + + val next = page.nextCursor + if (next == null) { stop = SyncStopReason.BOTTOM; break } + if (next == cursor) { stop = SyncStopReason.CURSOR_STUCK; break } + if (pages >= MAX_WINDOW_PAGES) { stop = SyncStopReason.MAX_PAGES; break } + cursor = next } - return Result.success(SyncResult(inserted = totalInserted)) + val result = SyncResult( + inserted = inserted, + scanned = scanned, + dropped = dropped, + pages = pages, + stopReason = stop + ) + // 命中即停 / 真追平都算成功;只有被截断、显著解析丢失或遇到异常才上报失败。 + // (个别脏记录不足以让整次同步失败,否则 lastSyncAt 会冻住、窗口守护常挂、后台会无限重试) + val droppedSignificantly = dropped >= 10 || (scanned > 0 && dropped * 2 >= scanned) + val abnormal = when { + stop == SyncStopReason.MAX_PAGES -> "page limit $MAX_WINDOW_PAGES reached" + stop == SyncStopReason.PARSE_ANOMALY -> "parse anomaly: dropped=$dropped" + stop == SyncStopReason.CURSOR_STUCK -> "cursor did not advance" + droppedSignificantly -> "dropped $dropped of $scanned record(s) while parsing" + else -> null + } + return if (errors.isEmpty() && abnormal == null) Result.success(result) + else Result.failure( + SyncError.PartialSync(inserted, errors + listOfNotNull(abnormal)) + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/domain/usecase/SyncUsageUseCase.kt b/app/src/main/java/com/rainy/token/domain/usecase/SyncUsageUseCase.kt index f2c10c2..5683f2b 100644 --- a/app/src/main/java/com/rainy/token/domain/usecase/SyncUsageUseCase.kt +++ b/app/src/main/java/com/rainy/token/domain/usecase/SyncUsageUseCase.kt @@ -79,16 +79,41 @@ class SyncUsageUseCase @Inject constructor( } } +/** 同步停止原因:把「真追平」与「被截断 / 遇到异常」区分开(旧实现不可区分,空洞因此无法诊断)。 */ +enum class SyncStopReason { + /** 服务端已经没有更多记录(真追平服务端窗口) */ + BOTTOM, + + /** 增量同步:整页记录本地都已存在(已接到已知数据),按启发式停在这里 */ + HIT_EXISTING, + + CURSOR_STUCK, + MAX_PAGES, + PAGE_ERROR, + PARSE_ANOMALY +} + data class SyncResult( val inserted: Int, - val totalCount: Int = 0 + val totalCount: Int = 0, + /** 本次扫过的服务端记录数(含已存在的) */ + val scanned: Int = 0, + /** 解析失败被丢弃的条数,>0 表示服务端有本地解析不了的数据 */ + val dropped: Int = 0, + val pages: Int = 0, + val stopReason: SyncStopReason = SyncStopReason.BOTTOM ) sealed class SyncError : Exception() { -class PartialSync(val inserted: Int, val errors: List) : SyncError() { -override val message: String = buildString { -append("部分同步完成:插入 ${inserted}条,${errors.size}页失败") -if (errors.isNotEmpty()) append("。${errors.first().take(200)}") -} -} + /** 已有同步任务在跑(进程级互斥),本次请求被跳过。 */ + class AlreadyRunning : SyncError() { + override val message: String = "同步正在进行中" + } + + class PartialSync(val inserted: Int, val errors: List) : SyncError() { + override val message: String = buildString { + append("部分同步完成:插入 ${inserted}条,${errors.size}页失败") + if (errors.isNotEmpty()) append("。${errors.first().take(200)}") + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/domain/usecase/UsageSyncCoordinator.kt b/app/src/main/java/com/rainy/token/domain/usecase/UsageSyncCoordinator.kt new file mode 100644 index 0000000..9a89dba --- /dev/null +++ b/app/src/main/java/com/rainy/token/domain/usecase/UsageSyncCoordinator.kt @@ -0,0 +1,112 @@ +package com.rainy.token.domain.usecase + +import android.content.Context +import android.content.SharedPreferences +import com.rainy.token.data.repository.CommandCodeUsageRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +/** + * 用量同步协调器:进程级单飞 + 同步状态持久化。 + * + * 为什么必须单独存在这一层: + * - 同一 workspace 会同时存在多个 UsageViewModel 实例(仪表盘卡片、详情页、概览页、 + * 双栏详情各自持有 ViewModelStore),ViewModel 内部的 syncing 标志无法互斥, + * 多入口同时同步会重复请求并互相竞争。 + * - UseCase 不是 @Singleton(调用方用 Provider.get() 每次新建实例), + * 实例内的 Mutex 不共享,所以互斥必须放在 @Singleton 的这一层。 + * + * 顺带统一管理(沿用既有 prefs,不破坏老版本已写入的数据): + * - 最近一次成功同步时间(usage_sync_at / sync_at_) + * - 后台同步开关(usage_sync_at / background_sync_enabled) + */ +@Singleton +class UsageSyncCoordinator @Inject constructor( + @ApplicationContext context: Context, + private val ccgoSyncProvider: Provider +) { + private val prefs: SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private val syncMutex = Mutex() + + private val _busy = MutableStateFlow(false) + + /** 是否有同步正在进行:跨页面共享,让所有入口的刷新按钮一起转圈。 */ + val busy: StateFlow = _busy.asStateFlow() + + /** + * CCGO 同步(进程级单飞)。 + * + * @param incremental true = 增量同步(整页命中即停,自动路径用:回到前台 / 8h 后台任务,省请求) + * false = 把服务端窗口拉满(用户主动点「立即同步」用,能把窗口内的洞补齐) + * + * 并发调用时直接返回 [SyncError.AlreadyRunning],不排队、不伪装成功。 + */ + suspend fun syncCcgoUsage(incremental: Boolean): Result { + if (!syncMutex.tryLock()) { + return Result.failure(SyncError.AlreadyRunning()) + } + _busy.value = true + return try { + val useCase = ccgoSyncProvider.get() + val result = if (incremental) useCase.incrementalSync() else useCase.refreshWindow() + if (result.isSuccess) { + saveLastSyncAt( + CommandCodeUsageRepository.CCGO_WORKSPACE_ID, + System.currentTimeMillis() + ) + } + result + } finally { + _busy.value = false + syncMutex.unlock() + } + } + + // ===== 同步时间 ===== + + fun lastSyncAt(workspaceId: String): Long = prefs.getLong(syncAtKey(workspaceId), 0L) + + /** 距上次成功同步是否已超过 [maxAgeMs];从未成功同步过也算过期。 */ + fun isSyncStale(workspaceId: String, maxAgeMs: Long = STALE_AFTER_MS): Boolean { + val last = lastSyncAt(workspaceId) + return last <= 0L || System.currentTimeMillis() - last > maxAgeMs + } + + fun saveLastSyncAt(workspaceId: String, ts: Long) { + prefs.edit().putLong(syncAtKey(workspaceId), ts).apply() + } + + // ===== 后台同步开关 ===== + + var backgroundSyncEnabled: Boolean + get() = prefs.getBoolean(KEY_BACKGROUND_SYNC, true) + set(value) { + prefs.edit().putBoolean(KEY_BACKGROUND_SYNC, value).apply() + } + + companion object { + /** 沿用既有 prefs 名与 key 格式,保证老版本写入的同步时间不丢失 */ + private const val PREFS_NAME = "usage_sync_at" + private const val KEY_PREFIX = "sync_at_" + private const val KEY_BACKGROUND_SYNC = "background_sync_enabled" + + /** + * 窗口守护阈值:超过它没有成功同步,UI 提示「数据可能已过期」。 + * 服务端窗口约 24h,取 12h 留出一次补救机会。 + */ + const val STALE_AFTER_MS = 12 * 60 * 60 * 1000L + + /** 前台进入页面时的自动同步最小间隔(距上次成功同步超过它才自动同步)。 */ + const val FOREGROUND_MIN_INTERVAL_MS = 8 * 60 * 60 * 1000L + + private fun syncAtKey(workspaceId: String) = KEY_PREFIX + workspaceId + } +} diff --git a/app/src/main/java/com/rainy/token/sync/UsageSyncWorker.kt b/app/src/main/java/com/rainy/token/sync/UsageSyncWorker.kt new file mode 100644 index 0000000..05bb6f2 --- /dev/null +++ b/app/src/main/java/com/rainy/token/sync/UsageSyncWorker.kt @@ -0,0 +1,94 @@ +package com.rainy.token.sync + +import android.content.Context +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.rainy.token.data.repository.RepositoryError +import com.rainy.token.domain.usecase.SyncError +import com.rainy.token.domain.usecase.UsageSyncCoordinator +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import java.util.concurrent.TimeUnit + +/** Hilt EntryPoint:让 Worker(非 Hilt 组件)拿到 @Singleton 的同步协调器。 */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface UsageSyncEntryPoint { + fun usageSyncCoordinator(): UsageSyncCoordinator +} + +/** + * 后台用量同步 Worker。 + * + * 定位:**兜底**。WorkManager 不保证准时(Doze / 厂商省电会把它推迟,app 被强行停止后 + * 要等用户手动打开才恢复),所以真正的主力是前台进入页面时的自动同步。 + * 但只要一天里任意一层成功同步过一次,就不会丢数据(服务端明细窗口约 24h)。 + */ +class UsageSyncWorker( + appContext: Context, + params: WorkerParameters +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + val coordinator = EntryPointAccessors + .fromApplication(applicationContext, UsageSyncEntryPoint::class.java) + .usageSyncCoordinator() + + // 用户在设置里关掉了后台同步 → 直接成功退出(不重试、不报警) + if (!coordinator.backgroundSyncEnabled) return Result.success() + + // 自动路径走增量同步(整页命中即停):健康状态下 1 个请求就能确认「已接到已知数据」, + // 省电省流量;补窗口内的洞交给用户主动点的「立即同步」(refreshWindow)。 + val result = coordinator.syncCcgoUsage(incremental = true) + return when (val error = result.exceptionOrNull()) { + null -> Result.success() + // 前台正在同步:不算失败,别和前台抢 + is SyncError.AlreadyRunning -> Result.success() + // 凭据失效 / 被替换 / 服务端响应无法解析:重试一万次也不会成功,直接结束(避免无谓耗电) + is RepositoryError.InvalidCredential, + is RepositoryError.CredentialChanged, + is RepositoryError.ParseError -> Result.success() + // 网络波动 / 服务端错误 / 限流(429):交给 WorkManager 的退避重试 + else -> Result.retry() + } + } +} + +/** + * 后台同步调度:每 8 小时一次,仅在网络可用时执行。 + * + * 用唯一任务名 + KEEP,重复调用不会堆积任务(例如每次冷启动都调用 apply())。 + */ +object UsageSyncScheduler { + + private const val WORK_NAME = "usage_sync_ccgo" + private const val INTERVAL_HOURS = 8L + + fun apply(context: Context, enabled: Boolean) { + val workManager = WorkManager.getInstance(context) + if (!enabled) { + workManager.cancelUniqueWork(WORK_NAME) + return + } + val request = PeriodicWorkRequestBuilder( + INTERVAL_HOURS, TimeUnit.HOURS + ).setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ).build() + + workManager.enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request + ) + } +} diff --git a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt index 39577f8..65daaa1 100644 --- a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt +++ b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt @@ -228,7 +228,7 @@ private fun CompactNavHost() { onOpenOverview = { navController.navigate(Routes.CCGO_USAGE_OVERVIEW) }, onOpenData = { navController.navigate(Routes.CCGO_USAGE_DATA) }, viewModel = chartVm, - clearViewModel = usageVm + isCcgo = true ) } composable(Routes.CCGO_USAGE_OVERVIEW) { @@ -433,7 +433,7 @@ private fun ExpandedDetailPane( onOpenOverview = { navController.navigate("overview") }, onOpenData = { navController.navigate("data") }, viewModel = chartVm, - clearViewModel = usageVm + isCcgo = true ) } composable("overview") { diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt b/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt index f893d09..8bd6818 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/CommandCodeUsageStatsCard.kt @@ -28,6 +28,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.rainy.token.R import com.rainy.token.ui.components.asString @@ -49,9 +51,14 @@ fun CommandCodeUsageStatsCard( val wid = com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID val key = "ccgo_$wid" val viewModel: UsageViewModel = hiltViewModel(key = key) - // 初始化 workspace,后续刷新自动走 CCGO 的 sync use case - LaunchedEffect(Unit) { + // 初始化 workspace(幂等),后续刷新自动走 CCGO 的 sync use case + LaunchedEffect(Unit) { viewModel.setWorkspace(wid) } + + // 进入 / 回到前台时若距上次成功同步超过阈值,自动补一次(前台主力防线)。 + // 用 ON_RESUME 而不是只靠首次组合:用户从后台切回来同样要能触发。 + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.setWorkspace(wid) + viewModel.syncIfStale() } val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -181,6 +188,16 @@ fun CommandCodeUsageStatsCard( ) } + // ─── 窗口守护提示:服务端明细只保留约 24h,超过 12h 未成功同步就有永久丢失风险 ─── + if (uiState.syncStale) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "⚠︎ " + stringResource(R.string.usage_sync_stale), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt index 261073c..d7be9cb 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageDetailScreen.kt @@ -80,19 +80,18 @@ fun UsageDetailScreen( onBack: () -> Unit, onOpenOverview: () -> Unit, onOpenData: () -> Unit = {}, - viewModel: UsageChartViewModel = hiltViewModel(), - clearViewModel: UsageViewModel? = null // non-null = CCGO, 显示清除按钮 + /** true = CCGO 详情(数据由 NavHost 的 setWorkspace 触发加载,不重复 load) */ + isCcgo: Boolean = false, + viewModel: UsageChartViewModel = hiltViewModel() ) { val state by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current // OCGO 首次加载(CCGO 由 NavHost 的 setWorkspace 触发,不重复 load) LaunchedEffect(Unit) { - if (clearViewModel == null) viewModel.load() + if (!isCcgo) viewModel.load() } - var showClearDialog by remember { mutableStateOf(false) } - var clearCountdown by remember { mutableStateOf(0) } var granularityExpanded by remember { mutableStateOf(false) } var modelExpanded by remember { mutableStateOf(false) } var showCustomDayPicker by remember { mutableStateOf(false) } @@ -110,7 +109,7 @@ fun UsageDetailScreen( topBar = { TopAppBar( title = { - val serviceLabel = if (clearViewModel == null) "OCGO" else "CommandCode" + val serviceLabel = if (isCcgo) "CommandCode" else "OCGO" Column { Text( stringResource(R.string.title_usage_detail), @@ -128,11 +127,6 @@ fun UsageDetailScreen( }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, stringResource(R.string.action_back)) } }, actions = { - if (clearViewModel != null) { - TextButton(onClick = { showClearDialog = true }) { - Text(stringResource(R.string.action_clear), color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.SemiBold, fontSize = 13.sp) - } - } TextButton(onClick = onOpenData) { Text(stringResource(R.string.action_view_raw_data), color = StrawberryPink, fontWeight = FontWeight.SemiBold, fontSize = 13.sp) } @@ -362,49 +356,7 @@ fun UsageDetailScreen( DetailRow(stringResource(R.string.chart_stack_input), formatTokenComma(state.buckets.sumOf { it.inputTokens })) DetailRow(stringResource(R.string.chart_stack_output), formatTokenComma(state.buckets.sumOf { it.outputTokens })) } - if (showClearDialog && clearViewModel != null) { - val cd = clearCountdown - androidx.compose.material3.AlertDialog( - onDismissRequest = { showClearDialog = false; clearCountdown = 0 }, - title = { Text("⚠️ " + stringResource(R.string.dialog_clear_title), fontWeight = FontWeight.Bold) }, - text = { - Column { - Text(stringResource(R.string.dialog_clear_body)) - Spacer(Modifier.height(12.dp)) - Text(stringResource(R.string.dialog_clear_confirm_question), fontWeight = FontWeight.SemiBold) - } - }, - confirmButton = { - TextButton( - onClick = { - showClearDialog = false - clearCountdown = 0 - clearViewModel?.clearAndResync() - onBack() - }, - enabled = cd == 0 - ) { - Text( - if (cd > 0) stringResource(R.string.action_confirm_countdown, cd) else stringResource(R.string.action_confirm_clear), - color = if (cd == 0) MaterialTheme.colorScheme.error else InkMuted - ) - } - }, - dismissButton = { - TextButton(onClick = { showClearDialog = false; clearCountdown = 0 }) { - Text(stringResource(R.string.action_cancel)) - } - } - ) - LaunchedEffect(showClearDialog) { - if (!showClearDialog) return@LaunchedEffect - clearCountdown = 3 - for (i in 3 downTo 1) { - kotlinx.coroutines.delay(1000) - clearCountdown = i - 1 - } - } - } + // 「清除」入口已移除:服务端明细只保留约 24h,清空本地缓存无法补回历史,只会永久丢失现有记录 } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt index 1ff3534..f78b72e 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/UsageViewModel.kt @@ -12,9 +12,10 @@ import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.RepositoryError import com.rainy.token.domain.model.Credential import com.rainy.token.domain.service.ServiceType -import com.rainy.token.domain.usecase.SyncCommandCodeUsageUseCase import com.rainy.token.domain.usecase.SyncError +import com.rainy.token.domain.usecase.SyncResult import com.rainy.token.domain.usecase.SyncUsageUseCase +import com.rainy.token.domain.usecase.UsageSyncCoordinator import com.rainy.token.ui.components.UiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers @@ -78,8 +79,8 @@ sealed class TimeFilter(@StringRes val labelRes: Int) { class UsageViewModel @Inject constructor( private val cacheProvider: Provider, private val syncUseCaseProvider: Provider, - private val syncCommandCodeUseCaseProvider: Provider, - private val credentialRepository: CredentialRepository + private val credentialRepository: CredentialRepository, + private val syncCoordinator: UsageSyncCoordinator ) : ViewModel() { private val _uiState = MutableStateFlow(UsageUiState()) @@ -88,24 +89,29 @@ class UsageViewModel @Inject constructor( private var workspaceIdOverride: String? = null private var loadGeneration = 0 // 递增:过时的 loadStatsInternal 结果自动丢弃 - /** 用量同步时间持久化(按 workspace 区分 OCGO / CCGO),重启后卡片仍能显示刷新时间。 */ - private fun syncAtPrefs(): android.content.SharedPreferences = - com.rainy.token.RainyTokenApplication.appContext.getSharedPreferences( - "usage_sync_at", - android.content.Context.MODE_PRIVATE - ) - - private fun syncAtKey(wid: String): String = "sync_at_$wid" - - private fun loadLastSyncAt(wid: String): Long = - syncAtPrefs().getLong(syncAtKey(wid), 0L) - - private fun saveLastSyncAt(wid: String, ts: Long) { - syncAtPrefs().edit().putLong(syncAtKey(wid), ts).apply() + init { + // 跨页面共享的同步状态(进程级单飞):协调器在跑时,CCGO 各页面的刷新按钮一起转圈; + // 后台 Worker 完成同步(busy 由真→假)时补一次数据重载,否则界面会停在旧数字上。 + viewModelScope.launch { + var wasBusy = false + syncCoordinator.busy.collect { busy -> + if (workspaceIdOverride == com.rainy.token.data.repository + .CommandCodeUsageRepository.CCGO_WORKSPACE_ID + ) { + _uiState.update { it.copy(syncing = busy) } + if (wasBusy && !busy) loadStats() + } + wasBusy = busy + } + } } + // 同步时间与后台同步开关统一由 [UsageSyncCoordinator] 持久化(沿用原 prefs:usage_sync_at / sync_at_) + /** 覆盖 workspaceId,用于 CCGO 等非 OCGO 服务。必须在 loadStats() 前调用。 */ fun setWorkspace(wid: String) { + // 幂等:进入 / 回到前台会重复调用,同值不重置状态,避免卡片闪一下空数据 + if (workspaceIdOverride == wid) return workspaceIdOverride = wid loadGeneration++ // 先清空数据防止 init 自动加载的 OCGO 数据闪一下 @@ -175,7 +181,8 @@ class UsageViewModel @Inject constructor( recordCount = totalCount, loading = false, dailyPage = 1, - lastSyncAt = loadLastSyncAt(wid) + lastSyncAt = syncCoordinator.lastSyncAt(wid), + syncStale = syncCoordinator.isSyncStale(wid) ) } } @@ -203,65 +210,80 @@ class UsageViewModel @Inject constructor( _uiState.update { it.copy(dailyPage = (it.dailyPage + 1).coerceAtMost(totalPages)) } } - fun sync() { + /** + * 手动同步(刷新按钮 / 下拉刷新):默认把服务端窗口拉满,能把窗口内的空洞补齐。 + * + * 自动路径([syncIfStale]、后台 Worker)传 [incremental] = true 走增量(整页命中即停),省请求。 + */ + fun sync(incremental: Boolean = false) { viewModelScope.launch { _uiState.update { it.copy(syncing = true) } val wid = workspaceIdOverride ?: workspaceId() + val isCcgo = workspaceIdOverride == com.rainy.token.data.repository + .CommandCodeUsageRepository.CCGO_WORKSPACE_ID + val result = withContext(Dispatchers.Default) { - val cache = cacheProvider.get() - if (workspaceIdOverride == com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID) { - val useCase = syncCommandCodeUseCaseProvider.get() - val count = cache.count(com.rainy.token.data.repository.CommandCodeUsageRepository.CCGO_WORKSPACE_ID) - if (count == 0) useCase.fullSync() else useCase.incrementalSync() + if (isCcgo) { + syncCoordinator.syncCcgoUsage(incremental = incremental) } else { + // OCGO:保持既有分页同步(服务端窗口与游标语义不同,本次不动) val useCase = syncUseCaseProvider.get() - val count = cache.count() - if (count == 0) useCase.fullSync() else useCase.incrementalSync() + val cache = cacheProvider.get() + if (cache.count() == 0) useCase.fullSync() else useCase.incrementalSync() } } - val syncedAt = System.currentTimeMillis() - if (result.isSuccess && wid != null) saveLastSyncAt(wid, syncedAt) - result.onSuccess { loadStats() } + + val error = result.exceptionOrNull() + // 已有同步在跑(其它页面 / 后台 Worker):不清空上次结果,按钮状态交给协调器的 busy 驱动。 + if (error is SyncError.AlreadyRunning) { + // 极小概率竞态:busy 刚转 false 又被判定为「正在跑」,此时不会有人再复位 syncing + _uiState.update { it.copy(syncing = syncCoordinator.busy.value) } + return@launch + } + + // OCGO 不走协调器,成功时间要在这里补写(否则卡片的「更新于 X」会永远停在旧值) + if (result.isSuccess && !isCcgo && wid != null) { + syncCoordinator.saveLastSyncAt(wid, System.currentTimeMillis()) + } + + // 失败(尤其 PartialSync)时通常也已经写入了一部分记录:必须重新加载, + // 否则界面停在旧数据上,用户会以为「刷新没生效」。 + // 这里无条件刷新(多一次全表统计读,代价很小);不依赖 busy 跳变,避免 StateFlow 合并导致漏刷新。 + loadStats() + _uiState.update { it.copy( syncing = false, - lastSyncResult = result.getOrNull()?.inserted ?: 0, - lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) }, - lastSyncAt = if (result.isSuccess && wid != null) syncedAt else it.lastSyncAt + lastSyncResult = syncInsertedCount(result), + lastSyncError = error?.let { syncErrorToUiText(it) }, + lastSyncAt = if (result.isSuccess && wid != null) syncCoordinator.lastSyncAt(wid) + else it.lastSyncAt ) } } } - /** 清除当前 workspace 的缓存数据并全量重新同步。 */ - fun clearAndResync() { - viewModelScope.launch { - val wid = workspaceIdOverride ?: return@launch - _uiState.update { it.copy(syncing = true) } - val cache = cacheProvider.get() - cache.deleteByWorkspaceId(wid) - invalidateData() - val useCase = syncCommandCodeUseCaseProvider.get() - val result = useCase.fullSync() - val syncedAt = System.currentTimeMillis() - if (result.isSuccess) saveLastSyncAt(wid, syncedAt) - result.onSuccess { loadStats() } - _uiState.update { - it.copy( - syncing = false, - lastSyncResult = result.getOrNull()?.inserted ?: 0, - lastSyncError = result.exceptionOrNull()?.let { syncErrorToUiText(it) }, - lastSyncAt = if (result.isSuccess) syncedAt else it.lastSyncAt - ) - } + /** + * 进入 / 回到前台时的自动同步(主力防线)。 + * + * 距上次成功同步超过 [UsageSyncCoordinator.FOREGROUND_MIN_INTERVAL_MS](8 小时)才真正发起, + * 避免每次切页面都打网络;服务端明细窗口约 24h,这一层保证「每天打开过就不会丢」。 + * 走增量同步(整页命中即停),省请求;补洞交给用户主动点的「立即同步」。 + */ + fun syncIfStale() { + val wid = workspaceIdOverride ?: return + if (syncCoordinator.isSyncStale(wid, UsageSyncCoordinator.FOREGROUND_MIN_INTERVAL_MS)) { + sync(incremental = true) } } - - private fun invalidateData() { - _uiState.value = UsageUiState() - } } +/** 同步结果里的新增条数:成功取 result,部分失败时从 PartialSync 异常里取(旧实现失败一律显示 0)。 */ +private fun syncInsertedCount(result: Result): Int = + result.getOrNull()?.inserted + ?: (result.exceptionOrNull() as? SyncError.PartialSync)?.inserted + ?: 0 + data class UsageUiState( val loading: Boolean = true, val syncing: Boolean = false, @@ -272,6 +294,8 @@ data class UsageUiState( val lastSyncResult: Int = 0, val lastSyncError: UiText? = null, val lastSyncAt: Long = 0, // 最近一次成功同步的 epoch ms(0 = 从未同步) + /** 距上次成功同步已超过窗口守护阈值:数据可能过期,UI 给出提示 */ + val syncStale: Boolean = false, val timeFilter: TimeFilter = TimeFilter.All, val dailyPage: Int = 1, val modelFilter: String? = null // null = 全部模型 diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt index da5d181..1c1cb90 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt @@ -28,6 +28,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -146,6 +148,12 @@ fun SettingsScreen( item { LanguageCard(onClick = { showLanguageDialog = true }) } + item { + BackgroundSyncCard( + enabled = uiState.backgroundSyncEnabled, + onToggle = { viewModel.setBackgroundSyncEnabled(it) } + ) + } item { TipsCard(onClick = { onOpenTips() }) } @@ -209,6 +217,51 @@ private fun LanguageCard(onClick: () -> Unit) { } } +/** + * 后台同步开关卡片。 + * + * 后台任务由 WorkManager 每 8 小时执行一次(不保证准时,系统省电策略可能使其延后), + * 关闭后仍有「进入页面自动同步」兜底,只是丢了「忘记打开 app」这层保险。 + */ +@Composable +private fun BackgroundSyncCard(enabled: Boolean, onToggle: (Boolean) -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "🔄", + style = MaterialTheme.typography.titleLarge + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.settings_background_sync_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.settings_background_sync_desc), + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + modifier = Modifier.padding(top = 2.dp) + ) + } + Switch( + checked = enabled, + onCheckedChange = onToggle, + colors = SwitchDefaults.colors(checkedTrackColor = StrawberryPink) + ) + } + } +} + @Composable private fun LanguageDialog(onDismiss: () -> Unit) { val context = LocalContext.current diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt index d2e4313..8f8765c 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt @@ -1,10 +1,14 @@ package com.rainy.token.ui.settings +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.domain.model.CredentialStatus +import com.rainy.token.domain.usecase.UsageSyncCoordinator +import com.rainy.token.sync.UsageSyncScheduler import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -13,12 +17,13 @@ import kotlinx.coroutines.launch import javax.inject.Inject /** - * 设置页 ViewModel。当前阶段只展示凭据状态列表,后续阶段会加入 - * 刷新间隔、预警阈值、深色模式等设置项。 + * 设置页 ViewModel:凭据状态列表 + 用量同步相关开关。 */ @HiltViewModel class SettingsViewModel @Inject constructor( - private val credentialRepository: CredentialRepository + private val credentialRepository: CredentialRepository, + private val syncCoordinator: UsageSyncCoordinator, + @ApplicationContext private val appContext: Context ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -33,13 +38,29 @@ class SettingsViewModel @Inject constructor( _uiState.update { it.copy(loading = true) } val statuses = credentialRepository.statusForAll() _uiState.update { - it.copy(loading = false, credentialStatuses = statuses) + it.copy( + loading = false, + credentialStatuses = statuses, + backgroundSyncEnabled = syncCoordinator.backgroundSyncEnabled + ) } } } + + /** + * 后台同步开关:写入偏好并立刻注册 / 取消 8 小时周期任务。 + * + * 关闭后前台(进入页面)同步仍然生效,只是不再有后台兜底。 + */ + fun setBackgroundSyncEnabled(enabled: Boolean) { + syncCoordinator.backgroundSyncEnabled = enabled + UsageSyncScheduler.apply(appContext, enabled) + _uiState.update { it.copy(backgroundSyncEnabled = enabled) } + } } data class SettingsUiState( val loading: Boolean = false, - val credentialStatuses: List = emptyList() + val credentialStatuses: List = emptyList(), + val backgroundSyncEnabled: Boolean = true ) \ No newline at end of file diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index 9ff2ddb..d19c520 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -141,7 +141,7 @@ 缓存写入 %1$s 暂无数据,点击 🔄 同步 ✓ 新增 %1$d 条 - 部分同步完成:插入 %1$d条,%2$d页失败 + 部分同步:新增 %1$d 条,%2$d 项异常,可再次同步 点击切换 所有时间按 UTC+0 计算 已切换到 UTC+8,图表按北京时间显示 @@ -347,12 +347,12 @@ 消费明细 调用次数明细 Token 明细 - 清除数据 - 此操作将清除所有 CommandCode 用量本地缓存并重新同步,需要几秒钟完成。 - 是否继续? - 确认(%1$ds) - 确认清除 关闭 + + + 数据可能已过期,建议立即同步 + 后台同步 + 有网络时每 8 小时自动同步一次 CommandCode 用量;系统省电策略可能使其延后。 从 %1$s 至 %1$s 应用自定义范围 diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml index f6a2db7..60dd8bc 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -141,7 +141,7 @@ 快取寫入 %1$s 暫無數據,點選 🔄 同步 ✓ 新增 %1$d 條 - 部分同步完成:插入 %1$d條,%2$d頁失敗 + 部分同步:新增 %1$d 條,%2$d 項異常,可再次同步 點選切換 所有時間按 UTC+0 計算 已切換到 UTC+8,圖表按北京時間顯示 @@ -347,11 +347,10 @@ 消費明細 呼叫次數明細 Token 明細 - 清除數據 - 此操作將清除所有 CommandCode 用量本地快取並重新同步,需要幾秒鐘完成。 - 是否繼續? - 確認(%1$ds) - 確認清除 + + 資料可能已過期,建議立即同步 + 後台同步 + 有網路時每 8 小時自動同步一次 CommandCode 用量;系統省電策略可能使其延後。 關閉 從 %1$s 至 %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c71e04..80b818f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -144,7 +144,7 @@ Cache write %1$s No data yet · tap 🔄 to sync ✓ %1$d new records - Partial sync: %1$d records inserted, %2$d pages failed + Partial sync: %1$d new, %2$d issue(s) — try again tap to switch All times in UTC+0 Switched to UTC+8, charts shown in Beijing time @@ -350,12 +350,12 @@ Spending breakdown Request breakdown Token breakdown - Clear data - This clears all cached CommandCode usage and re-syncs. It takes a few seconds. - Continue? - Confirm (%1$ds) - Yes, clear Close + + + Data may be out of date — tap sync now + Background sync + Sync CommandCode usage every 8 hours when network is available. System power saving may delay it. From %1$s To %1$s Apply custom range diff --git a/app/src/test/java/com/rainy/token/data/repository/CommandCodeUsageRepositoryTest.kt b/app/src/test/java/com/rainy/token/data/repository/CommandCodeUsageRepositoryTest.kt index 89e916a..cfbea7f 100644 --- a/app/src/test/java/com/rainy/token/data/repository/CommandCodeUsageRepositoryTest.kt +++ b/app/src/test/java/com/rainy/token/data/repository/CommandCodeUsageRepositoryTest.kt @@ -143,6 +143,27 @@ class CommandCodeUsageRepositoryTest { assertTrue(decoded.contains("\"id\":\"rec-${CommandCodeUsageRepository.PAGE_SIZE - 1}\"")) } + @Test + fun `full page with one unparsable record still yields next cursor`() { + // 回归:旧实现用「解析后条数 < PAGE_SIZE」判定到底, + // 一条记录缺少 createdAt 就会让整次同步被当成已完成,本地永久留下空洞。 + val body = buildString { + append("""{"usages":[""") + for (i in 0 until CommandCodeUsageRepository.PAGE_SIZE - 1) { + if (i > 0) append(",") + append(usageJson("rec-$i", 100L + i, 10L, 0.001)) + } + append(",{\"id\":\"broken-1\",\"tokensIn\":\"1\",\"tokensOut\":\"1\"}") // 缺 createdAt → 该条解析失败 + append("]}") + } + val page = CommandCodeUsageRepository.parseUsageResponse(body) + assertEquals(CommandCodeUsageRepository.PAGE_SIZE - 1, page.records.size) + assertEquals(CommandCodeUsageRepository.PAGE_SIZE, page.rawCount) + assertEquals(1, page.droppedCount) + // 满页 → 必须继续翻页,而不是判定「到底」 + assertNotNull(page.nextCursor) + } + @Test fun `legacy format stays compatible`() { val body = """