diff --git a/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt new file mode 100644 index 0000000..a71df37 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/repository/CommandCodeUsageRepository.kt @@ -0,0 +1,192 @@ +package com.rainy.token.data.repository + +import com.rainy.token.data.local.UsageRecord +import com.rainy.token.domain.model.Credential +import com.rainy.token.domain.service.ServiceType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Base64 +import java.util.Locale +import java.util.TimeZone +import javax.inject.Singleton + +/** + * CommandCode Go 用量记录仓库。 + * + * 调 JSON API 分页抓取 usage 记录: + * GET https://api.commandcode.ai/internal/usage?limit=50 + * GET https://api.commandcode.ai/internal/usage?limit=50&cursor= + * + * cursor 是末条记录的 { createdAt, id } 的 base64 编码。 + * 第一页不用 cursor。 + */ +@Singleton +class CommandCodeUsageRepository( + private val okHttpClient: OkHttpClient, + private val credentialRepository: CredentialRepository +) { + private val json = Json { ignoreUnknownKeys = true } + private val apiBase = "https://api.commandcode.ai" + + companion object { + const val PAGE_SIZE = 100 + /** cost / DENOM = USD */ + const val COST_DENOM = 100_000_000L + private const val CCGO_WORKSPACE_ID = "commandcode" + } + + private suspend fun getApiKey(): String { + val c = credentialRepository.get(ServiceType.COMMANDCODE_GO) + ?: throw RepositoryError.InvalidCredential() + if (c !is Credential.ApiKeyCredential || c.key.isBlank()) { + throw RepositoryError.InvalidCredential() + } + return c.key + } + + /** + * 获取指定游标页的用量记录。 + * cursor=null 为最新页。 + * 返回 (记录列表, 下一页游标)。如果返回的列表长度 < PAGE_SIZE,表示到底。 + */ + suspend fun fetchPage(cursor: String?): Result, String?>> = + withContext(Dispatchers.IO) { + val apiKey = try { + getApiKey() + } catch (e: RepositoryError) { + return@withContext Result.failure(e) + } + + val url = buildString { + append("$apiBase/internal/usage?limit=$PAGE_SIZE") + if (cursor != null) append("&cursor=$cursor") + } + + val request = Request.Builder() + .url(url) + .header("Authorization", "Bearer $apiKey") + .header("Accept", "application/json") + .header("Origin", "https://commandcode.ai") + .header("Referer", "https://commandcode.ai/") + .get() + .build() + + val response = try { + okHttpClient.newCall(request).execute() + } catch (e: IOException) { + return@withContext Result.failure(RepositoryError.Network(e)) + } + + response.use { resp -> + if (!resp.isSuccessful) { + if (resp.code == 401 || resp.code == 403) { + return@withContext Result.failure(RepositoryError.InvalidCredential()) + } + return@withContext Result.failure(RepositoryError.ServerError(resp.code)) + } + + val body = resp.body?.string() ?: return@withContext Result.failure( + RepositoryError.ParseError("响应体为空") + ) + + val records = parseUsageResponse(body) + Result.success(records) + } + } + + /** + * 解析 JSON 响应。 + */ + private fun parseUsageResponse(body: String): Pair, String?> { + val root = json.parseToJsonElement(body).jsonObject + val usages = root["usages"]?.jsonArray ?: return emptyList() to null + + val records = usages.mapNotNull { elem -> + val obj = elem.jsonObject + parseUsageObject(obj) + } + + // 从最后一条记录计算下一页 cursor + val nextCursor = if (records.size >= PAGE_SIZE) { + val last = records.last() + encodeCursor(last.id, last.timeCreated) + } else null + + return records to nextCursor + } + + private fun parseUsageObject(obj: JsonObject): UsageRecord? { + val id = obj["id"]?.jsonPrimitive?.content ?: return null + val createdAt = obj["createdAt"]?.jsonPrimitive?.content ?: return null + val timeCreated = parseIsoDate(createdAt) ?: return null + + val tokensIn = obj["tokensIn"]?.jsonPrimitive?.content?.toLongOrNull() ?: 0L + val tokensOut = obj["tokensOut"]?.jsonPrimitive?.content?.toLongOrNull() ?: 0L + val tokensTotal = obj["tokensTotal"]?.jsonPrimitive?.content?.toLongOrNull() ?: 0L + + val creditsTotal = obj["creditsTotal"]?.jsonPrimitive?.content?.toDoubleOrNull() ?: 0.0 + val cost = (creditsTotal * COST_DENOM).toLong() + + val meta = obj["meta"]?.jsonObject + val model = meta?.get("model")?.jsonPrimitive?.content ?: "" + val provider = meta?.get("provider")?.jsonPrimitive?.content ?: "" + val cacheReadInputTokens = meta?.get("cacheReadInputTokens")?.jsonPrimitive?.content?.toLongOrNull() ?: 0L + + return UsageRecord( + id = id, + workspaceId = CCGO_WORKSPACE_ID, + timeCreated = timeCreated, + timeUpdated = timeCreated, + model = model, + provider = provider, + inputTokens = tokensIn, + outputTokens = tokensOut, + reasoningTokens = 0L, + cacheReadTokens = cacheReadInputTokens, + cacheWrite5mTokens = 0L, + cacheWrite1hTokens = 0L, + cost = cost, + keyId = "", + sessionId = "", + enrichmentPlan = "" + ) + } + + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'X'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private val dateFormatNoMillis = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'X'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private fun parseIsoDate(iso: String): Long? { + // 处理末尾 Z 和时区偏移 + val normalized = iso + .replace("Z", "X") + .replace(Regex("""[+-]\d{2}:\d{2}$"""), "X") + return try { + dateFormat.parse(normalized)?.time + ?: dateFormatNoMillis.parse(normalized)?.time + } catch (_: Exception) { null } + } + + /** 从记录信息编码为 base64 cursor */ + private fun encodeCursor(id: String, timeCreated: Long): String { + val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + val createdAt = sdf.format(java.util.Date(timeCreated)) + val cursorJson = """{"createdAt":"$createdAt","id":"$id"}""" + return Base64.getUrlEncoder().withoutPadding().encodeToString(cursorJson.toByteArray()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/di/NetworkModule.kt b/app/src/main/java/com/rainy/token/di/NetworkModule.kt index 66669b5..fa29abd 100644 --- a/app/src/main/java/com/rainy/token/di/NetworkModule.kt +++ b/app/src/main/java/com/rainy/token/di/NetworkModule.kt @@ -10,6 +10,7 @@ import com.rainy.token.data.local.usageCacheDataStore import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.DeepSeekRepository import com.rainy.token.data.repository.CommandCodeGoRepository +import com.rainy.token.data.repository.CommandCodeUsageRepository import com.rainy.token.data.repository.OpenCodeGoRepository import com.rainy.token.data.repository.OpenCodeUsageRepository import dagger.Module @@ -102,6 +103,16 @@ object NetworkModule { balanceCache: BalanceCache ): OpenCodeGoRepository = OpenCodeGoRepository(okHttpClient, credentialRepository, balanceCache) + /** + * CommandCode Go 用量仓库。 + */ + @Provides + @Singleton + fun provideCommandCodeUsageRepository( + okHttpClient: OkHttpClient, + credentialRepository: CredentialRepository + ): CommandCodeUsageRepository = CommandCodeUsageRepository(okHttpClient, credentialRepository) + /** * CommandCode Go 仓库:API Key 认证,调 JSON API。 */ diff --git a/app/src/main/java/com/rainy/token/domain/usecase/SyncCommandCodeUsageUseCase.kt b/app/src/main/java/com/rainy/token/domain/usecase/SyncCommandCodeUsageUseCase.kt new file mode 100644 index 0000000..2966c24 --- /dev/null +++ b/app/src/main/java/com/rainy/token/domain/usecase/SyncCommandCodeUsageUseCase.kt @@ -0,0 +1,81 @@ +package com.rainy.token.domain.usecase + +import com.rainy.token.data.local.UsageCache +import com.rainy.token.data.repository.CommandCodeUsageRepository +import javax.inject.Inject +import javax.inject.Provider + +/** + * CommandCode Go 用量同步 UseCase。 + * + * 游标协议:每页返回 (记录列表, 下一页游标)。 + * - cursor=null → 最新页 + * - 返回的列表长度 < PAGE_SIZE → 到底 + * + * ## 全量同步 + * 从 cursor=null 逐页抓取直至最后一页。 + * + * ## 增量同步 + * 从 cursor=null 逐页抓取,每页比对本地已有 ID, + * 当某页全部记录都已存在时停止。 + */ +class SyncCommandCodeUsageUseCase @Inject constructor( + private val usageRepoProvider: Provider, + private val cacheProvider: Provider +) { + suspend fun fullSync(): Result { + val repo = usageRepoProvider.get() + val cache = cacheProvider.get() + var cursor: String? = null + var totalInserted = 0 + val errors = mutableListOf() + + while (true) { + val pageResult = repo.fetchPage(cursor) + if (pageResult.isFailure) { + errors.add("cursor=${cursor?.take(20)}: ${pageResult.exceptionOrNull()?.message}") + break + } + val (records, nextCursor) = pageResult.getOrThrow() + if (records.isEmpty()) break + + val before = cache.count() + cache.insertAll(records) + totalInserted += (cache.count() - before) + + if (records.size < CommandCodeUsageRepository.PAGE_SIZE) break + cursor = nextCursor + } + + return if (errors.isEmpty()) Result.success(SyncResult(inserted = totalInserted)) + else Result.failure(SyncError.PartialSync(totalInserted, errors)) + } + + suspend fun incrementalSync(): Result { + val repo = usageRepoProvider.get() + val cache = cacheProvider.get() + var cursor: String? = null + var totalInserted = 0 + + while (true) { + val pageResult = repo.fetchPage(cursor) + if (pageResult.isFailure) return Result.failure(pageResult.exceptionOrNull()!!) + + val (records, nextCursor) = pageResult.getOrThrow() + if (records.isEmpty()) break + + val existingIds = cache.getAllIds() + 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 + } + + return Result.success(SyncResult(inserted = totalInserted)) + } +} \ No newline at end of file