feat(ccgo): 用量同步可靠性重构 —— 手动拉满窗口 + 8h 后台兜底 + 进前台增量

- 新增 UsageSyncCoordinator:进程级单飞(Mutex.tryLock)、busy/lastSyncAt/后台开关、8h 前台节流

- 新增 UsageSyncWorker:WorkManager 8h 周期(NetworkType.CONNECTED,唯一任务名),设置页可关

- 手动同步改为 refreshWindow:拉满服务端当前窗口(实测约 24h),不再把「本地已有」当停止信号

- 自动同步走 incrementalSync:保留「整页命中即停」并区分 HIT_EXISTING 与真追平(防服务端将来放开窗口)

- 修复造洞/掩盖洞缺陷:满页判定改用服务端原始条数、触顶/解析异常/游标卡住不再伪装成功、插入计数改用 DAO 返回值

- 响应体读取与 JSON 解析异常不再穿出 fetchPage(前台同步挂在 viewModelScope 上会崩主线程)

- 详情页移除「清除」按钮与确认弹窗;卡片新增「数据可能已过期」提示;设置页新增「后台同步」开关

- 三语言 strings.xml 同步增删文案
This commit is contained in:
WaterRain 2026-09-19 02:35:35 +00:00
parent 3bef8cc3f1
commit 67cb816ec7
No known key found for this signature in database
18 changed files with 729 additions and 197 deletions

View File

@ -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)

View File

@ -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 {

View File

@ -45,11 +45,17 @@ class UsageCache(
emit(dao.getAll().map { it.toDomain() })
}
suspend fun insertAll(newRecords: List<UsageRecord>) {
/**
* 批量插入,返回**真正写入**的条数。
*
* DAO 使用 OnConflictStrategy.IGNORE:已存在的 id 会被忽略且返回 -1L,可据此精确计数。
* 旧实现用全表 count() 差值统计插入数,会被并发的其它服务同步污染。
*/
suspend fun insertAll(newRecords: List<UsageRecord>): 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? {

View File

@ -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<UsageRecord>,
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=<base64>
* GET https://api.commandcode.ai/internal/usage?limit=100
* GET https://api.commandcode.ai/internal/usage?limit=100&cursor=<base64>
*
* 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<List<UsageRecord>, String?> {
internal fun parseUsageResponse(body: String): UsagePage {
val root = json.parseToJsonElement(body).jsonObject
val usages = root["usages"]?.jsonArray ?: return emptyList<UsageRecord>() 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<Pair<List<UsageRecord>, String?>> =
suspend fun fetchPage(cursor: String?): Result<UsagePage> =
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)
}
}
}

View File

@ -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<CommandCodeUsageRepository>,
private val cacheProvider: Provider<UsageCache>
) {
/** 防御性页数上限:正常窗口(1 天 ≤ 数十页)远不会触及,防止游标异常导致死循环 */
private companion object {
/** 全量拉取的防御性页数上限:防止游标异常导致死循环 */
const val MAX_PAGES = 500
/** 立即同步(refreshWindow)的页数上限:100 条/页 → 5000 条;服务端 24h 窗口远小于此 */
const val MAX_WINDOW_PAGES = 50
}
suspend fun fullSync(): Result<SyncResult> {
@ -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<SyncResult> {
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<String>()
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<SyncResult> {
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<String>()
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()!!)
val (records, nextCursor) = pageResult.getOrThrow()
if (records.isEmpty()) break
if (nextCursor == cursor) break // 游标未前进,防死循环
// 按 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 (newRecords.isEmpty()) break
cache.insertAll(newRecords)
totalInserted += newRecords.size
if (records.size < CommandCodeUsageRepository.PAGE_SIZE) break
cursor = nextCursor
if (++pages >= MAX_PAGES) break // 防御性上限
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
}
return Result.success(SyncResult(inserted = totalInserted))
val page = pageResult.getOrThrow()
pages++
scanned += page.rawCount
dropped += page.droppedCount
if (page.records.isEmpty() && page.rawCount > 0) {
// 服务端有数据但整页解析失败:按异常处理,不停成「已接到旧数据」
stop = SyncStopReason.PARSE_ANOMALY
break
}
// 整页命中判定基于服务端原始条数:个别记录解析失败不应让整页被误判为「已存在」
val newRecords = page.records.filter { it.id !in existingIds }
if (page.rawCount > 0 && newRecords.isEmpty()) {
// 整页都是已知数据 → 已接到旧数据,停止(保留既有启发式)
stop = SyncStopReason.HIT_EXISTING
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
}
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))
)
}
}

View File

@ -79,12 +79,37 @@ 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 AlreadyRunning : SyncError() {
override val message: String = "同步正在进行中"
}
class PartialSync(val inserted: Int, val errors: List<String>) : SyncError() {
override val message: String = buildString {
append("部分同步完成:插入 ${inserted}条,${errors.size}页失败")

View File

@ -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_<workspaceId>)
* - 后台同步开关(usage_sync_at / background_sync_enabled)
*/
@Singleton
class UsageSyncCoordinator @Inject constructor(
@ApplicationContext context: Context,
private val ccgoSyncProvider: Provider<SyncCommandCodeUsageUseCase>
) {
private val prefs: SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val syncMutex = Mutex()
private val _busy = MutableStateFlow(false)
/** 是否有同步正在进行:跨页面共享,让所有入口的刷新按钮一起转圈。 */
val busy: StateFlow<Boolean> = _busy.asStateFlow()
/**
* CCGO 同步(进程级单飞)。
*
* @param incremental true = 增量同步(整页命中即停,自动路径用:回到前台 / 8h 后台任务,省请求)
* false = 把服务端窗口拉满(用户主动点「立即同步」用,能把窗口内的洞补齐)
*
* 并发调用时直接返回 [SyncError.AlreadyRunning],不排队、不伪装成功。
*/
suspend fun syncCcgoUsage(incremental: Boolean): Result<SyncResult> {
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
}
}

View File

@ -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<UsageSyncWorker>(
INTERVAL_HOURS, TimeUnit.HOURS
).setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
).build()
workManager.enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
request
)
}
}

View File

@ -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") {

View File

@ -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
)
}
}
}
}

View File

@ -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,清空本地缓存无法补回历史,只会永久丢失现有记录
}
}

View File

@ -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<UsageCache>,
private val syncUseCaseProvider: Provider<SyncUsageUseCase>,
private val syncCommandCodeUseCaseProvider: Provider<SyncCommandCodeUsageUseCase>,
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_<wid>)
/** 覆盖 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,64 +210,79 @@ 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 syncedAt = System.currentTimeMillis()
if (result.isSuccess && wid != null) 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 && wid != null) syncedAt 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() }
if (cache.count() == 0) useCase.fullSync() else useCase.incrementalSync()
}
}
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) syncedAt else it.lastSyncAt
lastSyncResult = syncInsertedCount(result),
lastSyncError = error?.let { syncErrorToUiText(it) },
lastSyncAt = if (result.isSuccess && wid != null) syncCoordinator.lastSyncAt(wid)
else it.lastSyncAt
)
}
}
}
private fun invalidateData() {
_uiState.value = UsageUiState()
/**
* 进入 / 回到前台时的自动同步(主力防线)。
*
* 距上次成功同步超过 [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)
}
}
}
/** 同步结果里的新增条数:成功取 result,部分失败时从 PartialSync 异常里取(旧实现失败一律显示 0)。 */
private fun syncInsertedCount(result: Result<SyncResult>): Int =
result.getOrNull()?.inserted
?: (result.exceptionOrNull() as? SyncError.PartialSync)?.inserted
?: 0
data class UsageUiState(
val loading: Boolean = true,
@ -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 = 全部模型

View File

@ -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

View File

@ -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<CredentialStatus> = emptyList()
val credentialStatuses: List<CredentialStatus> = emptyList(),
val backgroundSyncEnabled: Boolean = true
)

View File

@ -141,7 +141,7 @@
<string name="usage_cache_write">缓存写入 %1$s</string>
<string name="usage_no_data_sync">暂无数据,点击 🔄 同步</string>
<string name="usage_new_records">✓ 新增 %1$d 条</string>
<string name="sync_partial">部分同步完成:插入 %1$d条,%2$d页失败</string>
<string name="sync_partial">部分同步:新增 %1$d 条,%2$d 项异常,可再次同步</string>
<string name="usage_click_to_switch"> 点击切换</string>
<string name="usage_utc0_note">所有时间按 UTC+0 计算</string>
<string name="msg_utc8_on">已切换到 UTC+8,图表按北京时间显示</string>
@ -347,12 +347,12 @@
<string name="chart_detail_cost">消费明细</string>
<string name="chart_detail_requests">调用次数明细</string>
<string name="chart_detail_tokens">Token 明细</string>
<string name="dialog_clear_title">清除数据</string>
<string name="dialog_clear_body">此操作将清除所有 CommandCode 用量本地缓存并重新同步,需要几秒钟完成。</string>
<string name="dialog_clear_confirm_question">是否继续?</string>
<string name="action_confirm_countdown">确认(%1$ds)</string>
<string name="action_confirm_clear">确认清除</string>
<string name="action_close">关闭</string>
<!-- ══════════ 同步与后台同步 ══════════ -->
<string name="usage_sync_stale">数据可能已过期,建议立即同步</string>
<string name="settings_background_sync_title">后台同步</string>
<string name="settings_background_sync_desc">有网络时每 8 小时自动同步一次 CommandCode 用量;系统省电策略可能使其延后。</string>
<string name="range_from">从 %1$s</string>
<string name="range_to">至 %1$s</string>
<string name="action_apply_custom_range">应用自定义范围</string>

View File

@ -141,7 +141,7 @@
<string name="usage_cache_write">快取寫入 %1$s</string>
<string name="usage_no_data_sync">暫無數據,點選 🔄 同步</string>
<string name="usage_new_records">✓ 新增 %1$d 條</string>
<string name="sync_partial">部分同步完成:插入 %1$d條,%2$d頁失敗</string>
<string name="sync_partial">部分同步:新增 %1$d 條,%2$d 項異常,可再次同步</string>
<string name="usage_click_to_switch"> 點選切換</string>
<string name="usage_utc0_note">所有時間按 UTC+0 計算</string>
<string name="msg_utc8_on">已切換到 UTC+8,圖表按北京時間顯示</string>
@ -347,11 +347,10 @@
<string name="chart_detail_cost">消費明細</string>
<string name="chart_detail_requests">呼叫次數明細</string>
<string name="chart_detail_tokens">Token 明細</string>
<string name="dialog_clear_title">清除數據</string>
<string name="dialog_clear_body">此操作將清除所有 CommandCode 用量本地快取並重新同步,需要幾秒鐘完成。</string>
<string name="dialog_clear_confirm_question">是否繼續?</string>
<string name="action_confirm_countdown">確認(%1$ds)</string>
<string name="action_confirm_clear">確認清除</string>
<!-- ══════════ 同步與後台同步 ══════════ -->
<string name="usage_sync_stale">資料可能已過期,建議立即同步</string>
<string name="settings_background_sync_title">後台同步</string>
<string name="settings_background_sync_desc">有網路時每 8 小時自動同步一次 CommandCode 用量;系統省電策略可能使其延後。</string>
<string name="action_close">關閉</string>
<string name="range_from">從 %1$s</string>
<string name="range_to">至 %1$s</string>

View File

@ -144,7 +144,7 @@
<string name="usage_cache_write">Cache write %1$s</string>
<string name="usage_no_data_sync">No data yet · tap 🔄 to sync</string>
<string name="usage_new_records">✓ %1$d new records</string>
<string name="sync_partial">Partial sync: %1$d records inserted, %2$d pages failed</string>
<string name="sync_partial">Partial sync: %1$d new, %2$d issue(s) — try again</string>
<string name="usage_click_to_switch"> tap to switch</string>
<string name="usage_utc0_note">All times in UTC+0</string>
<string name="msg_utc8_on">Switched to UTC+8, charts shown in Beijing time</string>
@ -350,12 +350,12 @@
<string name="chart_detail_cost">Spending breakdown</string>
<string name="chart_detail_requests">Request breakdown</string>
<string name="chart_detail_tokens">Token breakdown</string>
<string name="dialog_clear_title">Clear data</string>
<string name="dialog_clear_body">This clears all cached CommandCode usage and re-syncs. It takes a few seconds.</string>
<string name="dialog_clear_confirm_question">Continue?</string>
<string name="action_confirm_countdown">Confirm (%1$ds)</string>
<string name="action_confirm_clear">Yes, clear</string>
<string name="action_close">Close</string>
<!-- ══════════ 同步与后台同步 ══════════ -->
<string name="usage_sync_stale">Data may be out of date — tap sync now</string>
<string name="settings_background_sync_title">Background sync</string>
<string name="settings_background_sync_desc">Sync CommandCode usage every 8 hours when network is available. System power saving may delay it.</string>
<string name="range_from">From %1$s</string>
<string name="range_to">To %1$s</string>
<string name="action_apply_custom_range">Apply custom range</string>

View File

@ -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 = """