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:
parent
3bef8cc3f1
commit
67cb816ec7
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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? {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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<String>) : 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<String>) : SyncError() {
|
||||
override val message: String = buildString {
|
||||
append("部分同步完成:插入 ${inserted}条,${errors.size}页失败")
|
||||
if (errors.isNotEmpty()) append("。${errors.first().take(200)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
94
app/src/main/java/com/rainy/token/sync/UsageSyncWorker.kt
Normal file
94
app/src/main/java/com/rainy/token/sync/UsageSyncWorker.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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") {
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,清空本地缓存无法补回历史,只会永久丢失现有记录
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,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 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,
|
||||
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 = 全部模型
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 = """
|
||||
|
||||
Loading…
Reference in New Issue
Block a user